데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      [K8S 보안 2탄] K8S 사용자 관리

      Kyu 24.04.22
      8,432 14 2
      DEVOTEE 요약
      Kubernetes(k8s) 클러스터 설치 시, 접근 설정 정보를 담은 kubeconfig 파일이 생성되고, 이 파일은 Kubernetes API 서버 접근 시 사용되며, 사용자와 그룹 별 역할 기반 접근 제어(RBAC)를 통해 권한을 관리합니다. Kubernetes는 인증에 X.509 클라이언트 인증서와 Bearer Token을 사용할 수 있고, 인가 처리를 위해 여러 모델을 제공합니다. 사용자 및 그룹 관리를 위해 Kubernetes 외부의 솔루션과 연동할 수 있으며, 예를 들어 Keycloak 같은 IAM을 사용하여 사용자 인증 정보를 관리할 수 있습니다.
      DEVOTEE 추천 블로그

      시작 : Discovery

      Kubernetes를 설치하면, kubeconfig 라는 Kubernetes 접근에 대한 설정이 Home Directory 및 .kube라는 Directory 및에 config란 이름으로 저장되어 있는 것을 발견 할 수 있습니다.

      kubeadm 으로 Kubernetes를 설치한 후 아래와 같은 메시지를 확인하실 수 있습니다.

      Your Kubernetes control-plane has initialized successfully!
      
      To start using your cluster, you need to run the following as a regular user:
      
        mkdir -p $HOME/.kube
        sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
        sudo chown $(id -u):$(id -g) $HOME/.kube/config
      
      Alternatively, if you are the root user, you can run:
      
        export KUBECONFIG=/etc/kubernetes/admin.conf

      config 파일을 보면, 아래와 같은 정보로 구성되어 있습니다.

      kubectl 이란 CLI는 Kube API Server에 접속하는 Client 입니다. 통상 K8S API Server는 통상 Private CA를 구성하여 사용함으로, Client에게 Private CA(K8S CA)정보와 Client가 사용할 Client인증서/Key정보를 전달해 주는 것 입니다.

      apiVersion: v1
      clusters:
      - cluster:
          certificate-authority-data: ## base64로 코딩된 K8S CA 인증서
          server: https://k8s.foo.com:6443  ## kube-api Server의 주소와 포트
        name: kubernetes
      contexts:
      - context:
          cluster: kubernetes
          user: kubernetes-admin
        name: kubernetes-admin@kubernetes
      current-context: kubernetes-admin@kubernetes
      kind: Config
      preferences: {}
      users:
      - name: kubernetes-admin
        user:
          client-certificate-data: ## base64로 코딩된 K8S CA가 발행 한 Client 인증서
          client-key-data: ## base64로 코딩된 Client 인증에 사용된 Private Key 

      좀더 자세히 탐색해 보겠습니다. Client 인증서에는 Client가 누구인지를 나타내는 항목이 있습니다. K8S는 인증서의 Subject(인증대상) 중 CN항목을 User, O항목을 Group으로 판단합니다.

      config 파일의 client-certificate-data 의 값을 Decoding하면 다음과 같은 Subject를 가지고 있습니다.

      O=kubeadm:cluster-admins, CN=kubernetes-admin

      이름만 봐도, Admin 권한인 것을 유추 할 수 있습니다. 탐색해 보는 김에 K8S의 ClutserRoleBinding과 ClusterRole을 확인합니다. 뒤에 말씀드리겠지만, K8S는 RBAC를 제공합니다. ClusterRole이란 권한이고, ClusterRoleBinding은 권한과 특정 대상과의 Mapping을 정의 한 것 입니다.


      먼저 ClusterRoleBinding을 탐색해 봅시다. 아래와 같이 kubectl client가 속한 group인 kubeadm:cluster-admins이 cluster-admin이라는 ClusterRole를 갖습니다.

      $ kubectl describe clusterrolebinding kubeadm:cluster-admins
      Name:         kubeadm:cluster-admins
      Labels:       <none>
      Annotations:  <none>
      Role:
        Kind:  ClusterRole
        Name:  cluster-admin
      Subjects:
        Kind   Name                    Namespace
        ----   ----                    ---------
        Group  kubeadm:cluster-admins

      그럼 cluster-admin 이란 ClusterRole이 어떤 권한을 갖는지 알아봅시다.

      모든 자원에 대한 모든 권한을 가진 Role 입니다. 우리가 추측했듯이 Admin(Super User)권한입니다.

      $ kubectl describe clusterrole cluster-admin
      Name:         cluster-admin
      Labels:       kubernetes.io/bootstrapping=rbac-defaults
      Annotations:  rbac.authorization.kubernetes.io/autoupdate: true
      PolicyRule:
        Resources  Non-Resource URLs  Resource Names  Verbs
        ---------  -----------------  --------------  -----
        *.*        []                 []              [*]
                   [*]                []              [*]

      우리는 이번 탐험으로 K8S는 X509 인증서 기준 상호인증을 제공하고, User/Group별 RBAC을 제공하는 것을 확인 했습니다. 그렇다면 User/Group은 어떻게 관리해야 할 까요? user별 config 파일은 누가 어떻게 전달해야 할 까요?

      조금 찾아 보니 K8S 자체는 User, Group이란 자원을 별도로 관리하지 않습니다. 뭘 어찌하라는 것 일까요?


      본 기고를 통해 차근차근 알아보도록 하겠습니다.

      Authentication, Authorization and Admission

      Kubernetes 사용자 관리에 대해서 알아 가기전에 Kubernetes의 구조에 대해서 복습해 보겠습니다.

      아래는 간단하게 도식화된 Kubernetes의 구조 입니다. Kubernetes는 API Server를 통해 Resource를 변경하는 시스템입니다. 즉 공용 게시판에 할일을 적으면 그림의 스폰츠밥(Client)들이 알아서 할일을 하고, 일을 마무리한 후, 작업명세서(리소스)의 상태를 변화 시키는 것 입니다. 결국은 어떤 Task를 행한다는 건 결국 중앙의 resource의 상태를 변화시키는 것으로 귀결됩니다.

      K8S구조


      따라서, Kubernetes의 인증/인가/승인은 API의 인증/인가/승인 입니다.

      Authentication

      Authentication은 인증입니다. 즉 API의 접근이 허가된 대상을 확인하는 절차 입니다.

      Kube API Server는 REST 형태의 API를 제공합니다. 따라서 일반적인 Web의 API 인증과 동일한 기술을 사용합니다.

      우리가 우리가 누구인지를 인증하기 위해서는 결국 신분증이 필요합니다. 따라서 인증을 이해하려면 어떤 종류의 신분증을 사용하는지 알아 보면 됩니다.

      K8S는 2가지 종류의 신분증을 인증에 사용합니다.

      X.509 Client 인증서 기반 인증

      1탄에서 언급된 Client 인증서 기반 인증입니다. Client인증서에는 Subject 항목 중, CN 항목에 User를 O항목에 Groups를 넣어서 자신이 누구인지 주장하며, 공개키 기반 전자서명이 포함되어 있습니다. 앞서 살펴본 Kubeconfig 파일에 들어 있는 인증 방식 입니다. 이 방식은 강한 보안을 제공하나, 미리 K8S CA 인증서를 필요한 Client에게 배포하고 K8S CA가 사용자 별로 Client 인증서를 발급하고 배포해야 합니다.

      K8S는 인증서 배포 및 관리 기능을 제공하고 있지 않습니다. 또한 K8S CA와 같은 Private Root CA를 배포하고 안전하게 관리하기는 어렵기 때문에, 일반적인 사용자 인증용으로 쓰기에는 어려움이 있습니다.

      Bearer Token 기반 인증

      HTTP Header에 Token (미리 약속된 증표)를 포함하여, 매 API Call 마다. 포함하여 인증을 하는 방식입니다. K8S는 두가지 종류의 Token을 지원합니다.

      Plain Text Token

      HTTP Header에 K8S에 미리 저장된 Token(그냥 미리 저정한 임의의 문자열 이라 생각하면 됩니다.)를 삽입하는 형태 입니다. 미리 정한 Token은 Static token file의 형태로 kube api server의 실행 시, 실행 argument로 입력됩니다.

      아래는 token file은 csv 파일 형태로, 각 raw는 아래와 같은 형식을 갖습니다.

      token,user,uid,"group1,group2,group3"

      이 방식의 경우, User / Group의 CURD시 마다 API Server를 재기동해야 합니다. 또한 HA 구조의 Cluster의 경우, kube API Server가 구동되는 Machine에 token 파일을 안전하게 배포/관리하는 기술이 별도로 필요합니다.

      JWT Token

      JWT 형식으로 Decoding된 Token을 사용합니다. JWT Token은 보다 유연한 Structure를 제공하고, 다양한 전자 서명을 제공합니다.

      Service Account Token

      K8S에서 구동되는 Container에서 K8S에 제근하기 위해 재공되는 Token으로 K8S 자원인 Service account에 Mapping되어 있는 Token입니다. Service Account Token은 K8S가 생성해 주는 Token으로 K8S의 SA key로 전자서명되어 있습니다.

      ID Token

      오래 동안 기다려 왔네요. 드디어 종착지에 왔습니다. ID Token은 JWT Token중 특정한 규격을 따르는 Token 입니다. OAuth2 라는 Authorization을 위한 규격이 있습니다. 이는 쉽게 생각하면 제 3자 권한 대행으로, 동일 가입자가 A와 B 두 서비스에 동시에 가입했을 떄, A Service에서 B Service를 사용할 수 있게 하기 위한 기술 입니다. 이때 Access Token이라는 JWT Token을 Bearer Token으로 사용합니다.

      이 OAuth2 구조위에, Authentication로 추가 한 것이, OIDC(Open ID Connect)라는 기술입니다. Wavve를 T맴버쉽 ID로도 로그인 하는 경우 사용되는 기술로 이해하면 됩니다. 이때 사용되는 JWT Token 형식이 ID Token 입니다.

      K8S는 OIDC 규격 기준으로 Authentication를 3rd Party Solution에 위임할 수 있습니다.

      아주 간단하게 요약하면 아래 그림으로 설명 가능합니다. 즉 사용자/그룹을 관리하는 별도 시스템을 외부에 두고, Kube API Server는 외부 시스템을 통해 Authentication을 실시 하는 것 입니다.

      K8S OIDC 간단


      Open ID Connect Tokens 라는 K8S 공식 문서에 자세한 절차는 명시되어 있습니다.

      API Server는 ID Token에 포함된 특정 Claim을 User와 Group에 매핑 할 수 있는 설정을 통해, 인증된 사용자의 Authorization / Admission 을 실시 합니다.

      Authorization

      Authorization은 etcd에 저장된 특정 자원에 대한 CURD에 대한 권한제어를 의미 합니다.

      K8S는 4가지 Authorization 을 제공 합니다.

      Node

      Kubelet의 권한에 대한 건으로 특별한 권한에 대한 제어 입니다.. 사용자에 관련된 것이 아닙니다.

      Wehbook

      OIDC 연결을 통해 인증을 했듯이, 권한도 3rd Party Solution에 위임하는 방식으로, 인증시 사용된 Token과 API request를 외부 Solution에 전달하여 특정 자원에 대한 권한 적용 여부를 결정하는 방식으로, 매우 Flexible하나 K8S Cluster별 사용되는 자원의 종류 및 생성된 자원등에 대한 정보를 항상 Sync. 시켜야 하기 때문에 유지보수 비용이 많이 듭니다.

      ABAC(Attribute Based Access Control)

      Kube API Server 기동 시, Authorization 관련 설정 파일을 Argument로 읽어서 권한 설정을 하는 방식 입니다. Plain Text Token과 유사한 Framework 입니다. 따라서 구조적으로 같은 단점을 갖습니다. 다만 Authorization 설정파일을 다양한 방식으로 변경가능(e.g. 시간이나 위치에 따라 다른 설정) 하지만 그 효용성에 대해서 의문입니다. 아래는 권한 파일의 예입니다. 판단은 각자 알아서 하시길 바랍니다.

      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"*",         "nonResourcePath": "*", "readonly": true}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"admin",     "namespace": "*",              "resource": "*",         "apiGroup": "*"                   }}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"system:controller_manager", "namespace": "*",              "resource": "*"                   		}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"kubelet",   "namespace": "*",              "resource": "*"                      		}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"kube_proxy",   "namespace": "*",              "resource": "*"}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"system:logging",   "namespace": "*",           "resource": "*"                  }}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"system:monitoring",   "namespace": "*",              "resource": "*"}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"system:serviceaccount:kube-system:default",   "namespace": "*", "resource": "*"}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"alice",     "namespace": "alice-space", "resource": "*",         "apiGroup": "*"                   }}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"bob",       "namespace": "alice-space", "resource": "*",         "apiGroup": "*", "readonly": true }}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"d6860ebb-b2f5-4eca-8ac6-3acd1cc17356",       "namespace": "shared-cluster-partition-test", "resource": "pods",         "apiGroup": "*"}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"d6860ebb-b2f5-4eca-8ac6-3acd1cc17356",       "namespace": "shared-cluster-partition-test", "resource": "namespace",         "apiGroup": "*", "readonly": true}}
      {"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user":"d6860ebb-b2f5-4eca-8ac6-3acd1cc17356",       "namespace": "shared-cluster-partition-test", "resource": "quota",         "apiGroup": "*", "readonly": true}}

      RBAC (Role Based Access Control)

      탐험에서 언급된 방식 입니다. Role 이란 Kubernete API에 대한 권한 Set 입니다. Role Binding은 특정 Role(권한)을 Kube API Server를 사용하는 대상에 제공하는 것으로, Kube APi Server를 사용하는 대상은 SW와 사람으로 나눌 수 있고, SW는 Service Account라는 K8S 자원으로 추상화 되면, 사람은 User와 Group 으로 추상화 됩니다.

      RBAC을 위한 설정은 etcd에 저장되는 K8S Resource로 K8S API를 통해 관리가 가능합니다. 따라서 사용자 관리를 위해서는 RBAC를 사용하는 것을 필자는 권고합니다.

      Admission

      Admission은 인증과 인가를 거친 후, 실제 Resource의 CRUD를 실행하기 전에 Fine grain 제어를 위해 사용됩니다. RBAC의 경우 REST API에 대한 권한으로 Resource 에 대한 CRUD에 대한 권한을 제어 합니다. 하지만 실재 업무에서는 Resource의 특정 Field를(e.g annotation)을 관리 측면에서 설정하지 않으면 실행 하지 못 하게 한다던가, audit을 위해 특정 annotation을 추가하는(mutation) 기능들이 요구 되기도 합니다. 이런 부분은 RBAC으로는 제공하기 어렵습니다. K8S는 Policy라 자원으로 이를 지원합니다. 또한 Extended Policy를 제공하기 위해 Admission Webhook을 통해 OPA Gatekeeper와 같은 외부 솔류션과 연동하기도 합니다. Admission은 받아들이게 따라서 좀더 정교한 RBAC이라 생각 될 수도 있으나 다른 개념입니다.

      Admission 에 대해서는 다른 기고 에서 더 자세히 다루고자 합니다.

      Conceptual Architecture

      사용자관리 기능을 제공하기 위해서는 K8S Cluster외 외부 Solution을 개발해야 하고, SW maintenance를 고려하면 최대한 검증되고 공개된 기술을 사용해야 합니다. 따라서 아래와 같은 시스템을 구축하는 것을 권고 합니다.


      Concept Architecture

      K8S 사용자 관리 환경 구축

      Conceptual Architecture 구성을 위해, Open Source를 활용하여, 전체 Framework을 구축해 봅시다.

      서비스 시나리오

      다음과 같은 시나리오를 가정해 봅시다. 어떤 K8S Cluster에는 boss와 bob 이라는 User가 있습니다. boss와 bob은 각각 아래와 같은 group에 속해 있고, 각 group은 다음과 같은 권한을 가지고 있습니다.

      • admin : k8s 관리자로 모든 권한을 가지고 있다.

      • LeastPriviliges : k8s에 어떤 namespace가 존재하고, 어떤 node가 붙어 있는지 확인만 해 볼 수 있다.

      • foo : foo 라는 namespace에 대한 접근 권한만 가지고 있다.

      User와 Group에 대한 관리는 IAM system(Keycloak)이 관리합니다. 또한 OIDC 연동을 통해 사용자는 Keycloak을 통해 ID Token을 획득하여 Authentication 을 수행하고, Authorization은 K8S RBAC의 의해서 제어됩니다.

      사용자관리시나리오

      K8S RBAC 설정

      시나리오의 권한을 K8S RBAC에 대응해 보겠습니다.

      admin과 LeastPriviliges는 cluster level의 권한 입니다. 따라서 ClusterRole과 ClutserRoleBinding 생성이 필요합니다.

      Admin의 경우, cluster-admins라는 K8S가 미리 재공하는 ClusterRole을 활용합니다.

      ## Admin Group을 위한 K8S RBAC 설정
      apiVersion: rbac.authorization.k8s.io/v1
      kind: ClusterRoleBinding
      metadata:
        name: devocean:admin   #admin 이란 이름이 일반적이라서 prefix를 붙였습니다.
      roleRef:
        apiGroup: rbac.authorization.k8s.io
        kind: ClusterRole
        name: cluster-admin
      subjects:
      - apiGroup: rbac.authorization.k8s.io
        kind: Group
        name: devocean:admin #admin 이 일반적이라서 prefix 붙임 

      LeastPriviliges는 당 시나리오에서 만들 Role이 필요함으로 ClusterRole과 ClutsterRoleBinindg을 모두 생성해야 합니다.

      ## LeastPriviliges를 위한 K8S RBAC 설정
      apiVersion: rbac.authorization.k8s.io/v1
      kind: ClusterRole
      metadata:
        name: devocean:leastpriviliges
      rules:    # 권한은 namespace와 node 자원에 대한 read
      - apiGroups:
        - '*'
        resources:
        - namespaces
        - nodes
        verbs:
        - get
        - list
      ---
      apiVersion: rbac.authorization.k8s.io/v1
      kind: ClusterRoleBinding
      metadata:
        name: devocean:leastpriviliges
      roleRef:
        apiGroup: rbac.authorization.k8s.io
        kind: ClusterRole
        name: devocean:leastpriviliges
      subjects:
      - apiGroup: rbac.authorization.k8s.io
        kind: Group
        name: devocean:leastpriviliges

      foo는 특정 namespace에 종속되는 group입니다. 따라서 Role과 Rolebinding 생성이 필요합니다. 따라서 foo라는 namespace를 만든 후, 아래 RBAC을 설정합니다.

      ## foo Group을 위한 K8S RBAC 설정
      apiVersion: rbac.authorization.k8s.io/v1
      kind: Role
      metadata:
        name: devocean:foo
        namespace: foo
      rules:
      - apiGroups:
        - '*'
        resources:
        - '*'
        verbs:
        - '*'
       ---
      apiVersion: rbac.authorization.k8s.io/v1
      kind: RoleBinding
      metadata:
        name: devocean:foo
        namespace: foo
      roleRef:
        apiGroup: rbac.authorization.k8s.io
        kind: Role
        name: devocean:foo
      subjects:
      - apiGroup: rbac.authorization.k8s.io
        kind: Group
        name: devocean:foo

      OIDC Provider 구축: Keycloak

      Keycloak은 가장 대표적인 IAM(Identity and Access Management) System 입니다. 또한 OIDC를 지원하는 대표적인 Platform 입니다. 보통 Keycloak 설치에 대한 많은 글 들은 Quick Start 수준의 동작 자체만을 확인하거나 OIDC 동작을 공부하기 위한 목적으로 쓰여져 있습니다. 본 기고에서는 그래도 어느정도 상용에 적용 할 수 있는 HA 구성의 Keycloak을 구성해 보기로 합니다.

      Target Conceptual Architecture

      본 기고에서는 Keycloak이 Muti-Site HA 구성에 사용하는 Active-passive 구성을 적용하여 K8S위에 Keycloak을 구축하려고 합니다. 필자의 판단으로는 Actvice-passive 구성이 K8S에 Keycloak을 HA로 구성하기 제일 적합한 구성이라고 판단했기 때문입니다. 사전 요구 시스템은 K8S 외부에 설치되어 있는DB (e.g. postgreSQL)입니다.

      Active-passive 구성은 아래와 같습니다

      keycloak HA architecture


      Keycloak은 일반적인 3 Tier Architecture를 가지고 있습니다. 또한 DB Access에 대한 속도 저하와 Keycloak Cluster구성시 Data consistency를 확보하기 위해서, Infinispan 이라는 분산 Cache Layer를 사용합니다.

      Active-passive 구성에서는 모든 Keycloak Instance는 독립적인 Active 상태로 Infinispan간 P2P로 모든 정보를 공유하여 사용 합니다. 참고로 Active-standby 구성은 Keycloak Instance마다 Actvie혹은 Standby role이 부과 되야 하기 때문에, Active를 선출하는 과정이 필요하고, 필연적으로 Cluster를 구성하는 Instance간 강한 연동이 필요합니다. 이에 반해 Active-passive구성은 Keycloak Instance들 자체는 홀로 독립적으로 동작하는 형태이고 다만 Cach coherenet는 Infinispan이라는 독립적은 system에 의해 확보되며, Infinispan의 option도 ad-hoc 이라는 완전 분산 방식을 채택하기 때문에, 이는 K8S의 Pod Scaling구성과도 잘 Match 됩니다.


      Infinispan은 Java에서 제공하는 JGRUOP를 통해 Cluster에 속한 Infinispan과 안정적인 Data공유를 위한 통신채널을 제공받습니다.

      일반적으로 Clustering(여러개의 Instance를 묶어 하나로 보이게하는 구조)을 위해서는 통상 아래와 같은 두개의 절차가 필요합니다.

      • Neighbor discovery : Cluster에 참여할 이웃(내가 아닌) 컴퓨터를 탐색하는 과정

      • Group communication : Cluster에 속한 이웃들에게 Multi-cast형태로 안정적으로 정보를 전달하는 과정

      통상 Broadcast Channel을 통한 Neighbor discovery 후, Unicast channel을 통해 Cluster Join 후, Unicast channel을 통한 Group communication을 실시하는 경우가 많이 있습니다.


      하지만, 일반적인 가상화 환경이나 CNI중 Overlay network인 경우 Broadcast channel를 제공하지 않는 경우가 많습니다. 또한 Broadcast 의 경우, Network이 분리된 Multi-site 구성에서는 사용할 수 없습니다.


      따라서 통상 TCP Broadcast란 기상천외한 방식이 사용되기도 합니다. TCP Multicast라고 통상 불려지는 이 동작은 다음과 같은 동작입니다.

      TCP Multicast/Broadcast

      가장 대표적인 구현방법은 DNS를 활용하는 방법입니다. Cluster에 참가하는 Node들끼리는 미리 FQDN을 공유 합니다(e.g. group-a.foo.com). Cluster에 참여할 Node들은 DNS에 약속한 FQDN과 Node IP를 각자 등록합니다. 이후 Cluster에 속한 Node에 Data를 보내고 싶은 Node는 Node들에 할당된 FQDN(e.g. group-a.foo.com)에 대한 DNS Query를 통해 Cluster에 속한 Node들을 알아낼 수 있습니다.

      TCP multicast

      하지만 이런방식의 경우, DNS라는 Infra에 접근 권한을 어떻게 안전하게 배포하는 문제를 해결해야 합니다. 예상하셨듯이 Infra에 대한 관리는 Infra가 설치된 환경 및 운영하는 조직의 성격등에 따라서 매우 다양해 집니다. 따라서 통산의 Framework들은 DNS Query를 통해 IP 주소 획득 후, 그룹 Communication을 어떻게 안정적으로 제공하는지에 대해서 고민하고 DNS에 IP를 등록하는 기능이 out of scope으로 관리하는 경우가 많습니다.

      Headless Service

      Headless Service를 이해하기 전에, K8S Service를 이해해야 합니다. Service는 기계적으로 말하면 다수의 Homogeneous pod들로 구성된 응용에 단일 End-point를 제공하는 것 입니다. 더 간단하게 이야기 하면 Load balancer를 제공하는 것 입니다.

      Service

      위 그림과 같이 대표 IP (192.168.10.100)을 부여하는 것 이고, 이 대표 IP를 Head라고 생각합니다. 대표IP는 Kubernetes가 자동으로 할당해 줍니다.

      Headless Service는 이 대표 IP를 자동으로 할당해 주지 않습니다. 따라서 bob.foo.svc.cluster.local과 같은 DNS query시 Service와 연결된 pod의 IP List를 응답합니다. Headless Service는 K8S가 제공하는 Service분배 알고리즘 대신, 다른 방법을 사용하기 위해서 많이 활용됩니다. 정확히 TCP Multicast를 구성하기 위해 누군가 해 줘야 할 일을 K8S은 Headless Service를 생성하는 것 만으로 쉽게 완료할 수 있습니다. 매우 다행이라고 생각됩니다.^^

      K8S Deployment

      이젠 K8S Cluster와 PostgreSQL이 설치 된 환경만(?!) 구축해 주세요. Keycloak의 구성은 간단합니다.

      미리 확보해 놓아야 할 List 입니다.

      • DB의 Admin ID/PW 확보

      • DB에 Keycloak 이란 이름의 Table 생성

      • DB와 Keycloak이 사용한 FQDN 확보 (e.g. postgre.foo.com , keycloak.foo.com)

      • Keycloak은 HTTPS 통신을 제공해야 함으로 인증서확보

      • 아래 manifast는 keycloak이라는 namespace에 설치됨을 가정함 keycloak namesapce 생성

      apiVersion: v1
      kind: Service
      metadata:
        name: keycloak
        labels:
          app: keycloak
      spec:
        ports:
        - name: http
          port: 8080
          targetPort: 8080
        - name: infinispan
          port: 7800
          targetPort: 7800
          protocol: TCP
        selector:
          app: keycloak
      ---
      ## headless Service
      apiVersion: v1
      kind: Service
      metadata:
        name: infinispan
      spec:
        clusterIP: None  ## headless Service 생성 방법
        selector:
          app: keycloak
      ---
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: keycloak
        labels:
          app: keycloak
      spec:
        replicas: 2
        strategy:
          type: "RollingUpdate"
          rollingUpdate:
            maxSurge: 1
            maxUnavailable: 1
        selector:
          matchLabels:
            app: keycloak
        template:
          metadata:
            labels:
              app: keycloak
          spec:
            containers:
            - name: keycloak
              image: quay.io/keycloak/keycloak:21.0.1
              args: ["start"]
              env:
              - name: KC_HOSTNAME
                value: "keycloak.foo.com"   ## Global Access 가능한 DNS Name
              - name: KC_HTTP_RELATIVE_PATH
                value: "/auth"                         ## Admin console에 login하려면 설정 필요
              - name: KC_HTTP_ENABLED
                value: "true"
              - name: KC_HEALTH_ENABLED
                value: "true"
              - name: KC_DB
                value: "postgres"
              - name: KC_DB_PASSWORD
                value: "1q2w3e4r"
              - name: KC_DB_URL_HOST
                value: "postgre.foo.com"
              - name: KC_DB_URL_PORT
                value: "5432"
              - name: KC_DB_USERNAME
                value: "postgres"
              - name: KEYCLOAK_ADMIN
                value: "admin"
              - name: KEYCLOAK_ADMIN_PASSWORD
                value: "admin"
              - name: KC_PROXY
                value: "edge"                 ## reverse proxy 뒷단에 KC가 존재한다는 의미
              - name: KC_CACHE_STACK
                value: "kubernetes"
              - name: KC_TRANSACTION_XA_ENABLED       ## 2PC 지원여부. DB Clustering에 맞김
                value: "false"
              - name: JAVA_OPTS
                value: "-Djgroups.dns.query=infinispan.keycloak.svc.cluster.local"   ## TCP multicast를 위한 DNS 주소
              ports:
              - name: http
                containerPort: 8080
              - name: infinispan
                containerPort: 7800
              readinessProbe:
                httpGet:
                  path: /auth/health
                  port: 8080
      ---
      apiVersion: networking.k8s.io/v1
      kind: Ingress
      metadata:
        name: keycloak
      spec:
        ingressClassName: nginx  ## nginx ingress controller 가정
        rules:
        - host: keycloak.foo.com
          http:
            paths:
            - path: /auth
              pathType: Prefix
              backend:
                service:
                  name: keycloak
                  port:
                    number: 8080
        tls:
        - hosts:
          - keycloak.foo.com
          secretName: keycloak-tls

      생성된 Keycloak의 admin password는 아래와 같은 명령어로 알 수 있습니다.

      kubectl get secret keycloak -n keycloak -o jsonpath={.data.admin-password} | base64 -d

      keycloak.foo.com/auth 를 부라우저에 입력하면 keycloak이 성공적으로 동작하는 것을 확일 할 수 있을 것 입니다.

      Keycloak 설정

      Keycloak에 적절한 설정을 위해서는 Keycloak과 OIDC를 모두 이해해야 합니다. 이를 위한 간단한 Diagram을 그려서 진행해 보겠습니다.

      keycloak mapper

      Keycloak은 ID Token을 제공하는 서버입니다. ID Token은 앞서 이야기 한 듯이 결국, Token 사용자의 자격을 인증하기 위한 수단입니다. 여기서 자격은 Claim(주장)이라고 합니다. 따라서 Keycloak은 내부에 저장하고 있는 사용자의 대한 정보(e.g username, group, email address 등)에서 Client가 자신을 증명하기 위해 필요한 Claim에 대응되는 값을 Mapping해서 전달해야 합니다. 따라서 Client 종류별 필요한 설정을 미리 해야 합니다.

      또한 Keycloak은 Multi-tenant를 지원하고 Tenant별로 독립된 사용자 관리가 가능합니다. 이를 Realm이라 부릅니다.


      따라서, Kubernetes RBAC에 적용하기 위한 Claim과 해당 Claim을 제공하기 위해 Keycloak의 어떤 내부 사용자 정보를 사용해야 하는지에 대한 설계가 필요합니다. 특히 어떤 Keycloak의 내부 정보를 ID Token의 claim으로 사용 할지에 대해서는 수많은 설계가 나올 수 있습니다.

      본 기고에서는 필자가 보기에 직관적이란 판단되는 방법에 의해서 설계 후, 해당 설계대로 Keycloak을 설정 하는 방법을 기술 하겠습니다.

      Keycloak의 UX를 제공합니다. 많은 기고들이 이 UX 기준으로 설명을 합니다. 하지만 본 기고에서는 REST API 기준으로 설명하겠습니다. 그 이유는 향후 독자들이 SW 개발을 통해 시스템 구축 시 조금이나마 도움이 되길 바라기 떄문입니다.

      참고
      문 기고는 아래 REST API 기준으로 설멍합니다.
      https://www.keycloak.org/docs-api/21.0.1/rest-api/index.html

      간략하게 Keycloak 설정을 도식화 하면 다음과 같습니다. 우선 Realm을 생성하고, Realm에 사용할 User와 Group을 생성합니다. 서비스 시나리오와 같이 boss와 bob 이라는 사용자와 devocean:admin, devocean:leastpriviliges, devocean:foo라는 group을 만들고, boss에는 devocean:admin group를 할당하고, bob에는 devocean:leastpriviliges와 devocean:foo를 할당합니다.

      그 후, OIDC설정을 위해서 ID token에 prefered_username과 groups를 cliam으로 전달 할 수 있게 Keycloak Client를 설정합니다. Client는 Keycloak의 자원으로 앞서 설명한 client config 입니다.

      keycloak 설정

      1. Keycloak Admin Token 확보

      Keycloak admin REST API를 접근하기 위해서는, Access Token이 필요합니다.

      이 예제는 아래와 같은 가정을 바탕으로 합니다.

      • Keycloak Endpoint : https:/keycloak.foo.com/auth

      • Admin ID/PW : admin / password

      • curl / jq 등 필요한 tool 은 사전 설치되어 있어야 합니다

      아래 Shell 명령어로 TOKEN이란 환경변수에 Access Token을 저장합니다.

      export TOKEN=$(curl -k -X POST https://keycloak.foo.com/auth/realms/master/protocol/openid-connect/token -d grant_type=password -d username=admin -d password=password -d client_id=admin-cli | jq -r '.access_token')

      2. Realm 생성

      Project A를 지칭하는 pro-a 라는 Realm을 생성한다.

      curl -v -k POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"realm":"org-a", "enabled":"true"}' https://keycloak.foo.com/auth/admin/realms

      3. User/Group 생성

      realm org-a 에 Group 생성

      curl -v -k POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"name" : "devocean:admin"}' https://keycloak.foo.com/auth/admin/realms/org-a/groups
      curl -v -k POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"name" : "devocean:leastpriviliges"}' https://keycloak.foo.com/auth/admin/realms/org-a/groups
      curl -v -k POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"name" : "devocean:foo"}' https://keycloak.foo.com/auth/admin/realms/org-a/groups

      User 생성

      #boss.json
      {
         "username": "boss",
         "enabled": "true",
         "credentials": [
           {
             "type": "password",
             "value": "qwer",
             "temporary": false ## 다음 로그인시, 비번 초기화 안됨
           }
         ]
      }
      
      #bob.json
      {
         "username": "bob",
         "enabled": "true",
         "credentials": [
           {
             "type": "password",
             "value": "qwer",
             "temporary": false
           }
         ]
      }

      realm org-a 에 User 생성

      curl -v -k -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d @boss.json  https://keycloak.foo.ocm/auth/admin/realms/org-a/users
      curl -v -k -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d @bob.json  https://keycloak.foo.com/auth/admin/realms/org-a/users

      User에 Group Mapping

      #get User ID
      export BOSS=$(curl -v -k -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keyclaok.foo.com/auth/admin/realms/org-a/users?username\=boss | jq -r .[0].id)
      export BOB=$(curl -v -k -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keyclaok.foo.com/auth/admin/realms/org-a/users?username\=bob | jq -r .[0].id)
      
      #get Group ID
      export K8SADMIN=$(curl -v -k -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keyclaok.foo.com/auth/admin/realms/org-a/groups?search\=devocean:admin | jq -r .[0].id)
      export K8SUSER=$(curl -v -k -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keyclaok.foo.com/auth/admin/realms/org-a/groups?search\=devocean:priviliges | jq -r .[0].id)
      export K8SFOO=$(curl -v -k -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keyclaok.foo.com/auth/admin/realms/org-a/groups?search\=devocean:foo | jq -r .[0].id)
      
      # User 'boss'에 'decocean:admin' group 할당
      curl -v -k -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keycloak.foo.com/auth/admin/realms/org-a/users/$BOSS/groups/$K8SADMIN
      
      # User 'bob'에 'k8s:foo' group , 'k8s:user" group 할당
      curl -v -k -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keycloak.foo.com/auth/admin/realms/org-a/users/$BOB/groups/$K8SUSER
      curl -v -k -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN"  https://keycloak.foo.com/auth/admin/realms/org-a/users/$BOB/groups/$K8SFOO

      4. Keycloak Client 생성

      Keycloak Client 생성

      ##client-k8s.json
      {
          "clientId" : "k8s",
          "enabled": true,
          "publicClient": true,
          "standardFlowEnabled": true,
          "directAccessGrantsEnabled":true, ## REST API를 통한 직접 접근. Token 검증등
          "protocol": "openid-connect"
      }

      REST API

      curl -v -k POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d @client.json https://keycloak.foo.com/auth/admin/realms/org-a/clients

      Keycloak은 다양한 mapper를 구성할 수 있습니다. 하나의 Realm(가입자DB)에 여러가지 Client가 Token을 요구 할 수도 있고, 각 Client가 사용하는 서비스 (e,g K8S, Grafana)는 각기 다른 RBAC을 제공 할 수 있습니다. 따라서 Client별로 독립적인 mapper 설정을 갖을 수 있습니다. Keycloak은 client내 client-scope이라는 자원을 복수로 가질 수 있고 이 client-scope에 mapper를 포함하여, 상황에 맞게 scope별 claim을 선택적으로 제공 할 수 있습니다.


      client-scope 생성

      언급한 2개의 mapper를 설정 합니다. 필요로한 최소한의 claim만 ID Token에 적용하게 설정했습니다.

      #clientscope.json
      {
         "name" : "oidc" ,
         "protocol" :  "openid-connect",
         "protocolMappers" : [
            {
              "name" : "groups" ,
              "protocol" : "openid-connect",
              "protocolMapper" : "oidc-group-membership-mapper",
              "consentRequired" : "false" ,
              "config": {
                           "full.path":"false",
                           "id.token.claim":"true",
                           "access.token.claim":"false",
                           "claim.name":"groups",
                           "userinfo.token.claim":"false"
                         }
            },
            {
              "name" : "user" ,
              "protocol" : "openid-connect",
              "protocolMapper" : "oidc-usermodel-property-mapper",
              "consentRequired": "false" ,
              "config": {
                           "user.attribute" : "username",
      		    "full.path":"false",
                           "id.token.claim":"true",
                           "access.token.claim":"false",
                           "claim.name":"preferred_username",
                           "userinfo.token.claim":"false"
                         }
            }
         ]
      }

      REST API

      curl -v -k POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d @clientscope.json https://keycloak.foo.com/auth/admin/realms/org-a/client-scopes

      Client에 Cleint-scope 등록

      default-scope으로 등록하여, 항상 oidc 란 scope에 정의된 claim 제공함

      #Get Client-Scope ID
      export SCOPE=$(curl -v -k GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" https://keycloak.foo.com/auth/admin/realms/org-a/client-scopes | jq -r '.[]|select(.name=="oidc").id')
      
      #Get Client ID
      export CLIENT=$(curl -v -k GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" https://keycloak.foo.com/auth/admin/realms/org-a/clients?clientId\=k8s | jq -r .[0].id)
      
      #client scope oidc를 k8s client의 default scope으로 등록하기
      curl -v -k -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" https://keycloak.foo.com/auth/admin/realms/org-a/clients/$CLIENT/default-client-scopes/$SCOPE

      아래와 같은 cli를 통해 token을 확인 할 수 있습니다. (OIIDC 기준으로 Token을 달라는 명령어입니다.)

      curl -k -X POST  https://keycloak.foo.com/auth/realms/org-a/protocol/openid-connect/token -d grant_type=password -d username=bob -d password=qwer -d client_id=k8s -d scope=openid | jq -r .id_token

      Kube API Server 설정

      이재 Kube API Server가 특정 ID Token을 Bearer Token형태로 가지고 API Call을 한 Response에 대해서 Token 거증을 하기 위해, OIDC Token verification에 대한 서버 연결 설정을 해야 합니다. control plane이 동작하는 node의 kube-apiserver 설정파일에 아래와 같이 OIDC 설정 부분을 추가해 줍니다.

      
      ## /etc/kubernetes/manifests/kube-apiserver.yaml
      
      apiVersion: v1
      kind: Pod
      metadata:
        annotations:
        ..
        name: kube-apiserver
        namespace: kube-system
      spec:
        containers:
        - command:
          - kube-apiserver
       ... ## OIDC 설정
          - --oidc-issuer-url=https://keycloak.foo.com/auth/realms/org-a
          - --oidc-client-id=k8s
          - --oidc-username-claim=preferred_username
          - --oidc-groups-claim=groups
       ...

      Kubelogin 설정

      이제 Kube API Server로 접근 신, Bearer Token으로 ID Token을 발급받아 사용하면 됩니다. 하지만 사용자는 Kubectl 이라는 CLI Tool을 이용해서, K8S에 접근합니다. 앞서 살펴본 것과 같이 Kube API Server는 Token, Client X.509 인증서 모두를 지원합니다. 따라서 kubeconfig 파일에 client 인증서를 user spec에 넣어서 사용해 왔습니다.

      K8S는 AuthN에 대해서 외부 시스템 연동을 지원하기 때문에, kubectl CLI입장에서도 이를 활용 할 수 있는 장치를 제공합니다.

      Client Authentication 란 Spec 입니다. 이를 통해서 Kubeconfig내 user에 대한 증명을 Kubectl plug-in을 통해 동적으로 확보하여 사용 살 수 있습니다.

      kubectl plugin

      kubectl은 plug-in을 제공하여, sub commend를 확장할 수 있는 수단을 제공합니다. 이는 Extend kubectl with plugins 란 문서로 K8S 공식 문서에 소개되고 있습니다.

      kubectl plugin 에 대해서는 Devocen의 아티클인 AKS로 쿠버네티스 시작하기 : kubectl plugin 에 자세히 다루고 있으나 참조 부탁드립니다.

      kubelogin 설치

      kubelogin은 kubectl plugin 중 하나로, oidc-login이란 sub commend를 제공합니다. 이를 통해 ID Token을 발급받을 수 있습니다.

      kubelogin은 Open Source지만 Azure에서 관리하는 SW 입니다. Public Cloud 사용 시, Public Cloud용 CLI를 샐행 시, Web Browser가 자동으로 뜨면서 ID/PW를 입력하는 절차가 진행되는 경우를 본 적 있을 것 입니다. 이떄 상요되는 기술 입니다.

      자세한 내용과 설치는 Github 공식 Page를 통해 실행해 주십시요.


      다음과 같은 명령을 통해, 동작을 확인 할 수 있습니다. 아래 명령어는 ID Token을 획득 하는 것 입니다.

      kubectl oidc-login get-token --oidc-issuer-url=https://keycloak.foo.com/auth/realms/org-a --oidc-client-id=k8s --username=bob --password=qwer

      kubeconfig 생성

      이미 앞서서 Token을 받는 Plug-in을 확인했습니다. 이 명령어를 활용하여 Client Authentication Spec을 kubeconfig 파일을 생성합니다.

      apiVersion: v1
      clusters:
      - cluster:
          certificate-authority-data: [당신은 K8S CA]
          server: https://[당신의 Kube API Server Endpoint. eg, boo.foo.com:6443]
        name: kubernetes
      contexts:
      - context:
          cluster: kubernetes
          user: oidc
          name: oidc-user@kubernetes
          current-context: oidc-user@kubernetes
      kind: Config
      preferences: {}
      users:
      - name: oidc
        user:
          exec:   ## User의 인증을 아래 설정에 따라 받아 온다는 설정입니다.
            apiVersion: client.authentication.k8s.io/v1beta1
            command: kubectl
            args:
            - oidc-login
            - get-token
            - --oidc-issuer-url=https://keycloak.foo.com/auth/realms/org-a
            - --oidc-client-id=k8s
            - --grant-type=password     

      마무리

      자 잘되는지 확인 봅시다. ID/PW 를 입력 하라고 CLI가 나옵니다 bob으로 로그인합니다.

      $ kubectl get ns
      Username: bob
      Password:
      NAME               STATUS   AGE
      calico-apiserver   Active   18d
      calico-system      Active   18d
      default            Active   18d
      foo                Active   14d
      kube-node-lease    Active   18d
      kube-public        Active   18d
      kube-system        Active   18d
      tigera-operator    Active   18d

      bob은 foo ns외 다른 ns에 대해서는 권한이 없습니다. 잘 되는지 확인해 봅시다.

      $ kubectl get pod -n kube-system
      Error from server (Forbidden): pods is forbidden: User "https://keycloak.foo.com/auth/realms/org-a#bob" cannot list resource "pods" in API group "" in the namespace "kube-system"

      그럼 boss로는 잘 되는지 알아 보겠습니다. 하지만 log-out은 어떻게 하는 것 일까요? 아쉽게도 현재로는 log-out은 명시적으로 제공되지 않고, cache를 지우는 방법을 써야 합니다.

      $ cd
      $ rm .kube/cache/oidc-login/*

      똑같은 일을 boss로 로그인 해서 실시해 보겠습니다.

      $ kubectl get pod -n kube-system
      Username: boss
      Password:
      NAME                                        READY   STATUS    RESTARTS         AGE
      coredns-76f75df574-kqwnj                    1/1     Running   9 (4h21m ago)    18d
      coredns-76f75df574-r9ng7                    1/1     Running   9 (4h21m ago)    18d
      etcd-ip-172-31-105-199                      1/1     Running   9 (4h21m ago)    18d
      kube-apiserver-ip-172-31-105-199            1/1     Running   4 (4h21m ago)    11d
      kube-controller-manager-ip-172-31-105-199   1/1     Running   10 (4h21m ago)   18d
      kube-proxy-cmr9z                            1/1     Running   9 (4h21m ago)    18d
      kube-scheduler-ip-172-31-105-199            1/1     Running   10 (4h21m ago)   18d

      긴 글이 끝났네요. 의도와 설계 배경 그리고 간단한 실습을 통한 실무적용까지 가능하게 글을 쓰려고 노력 중 입니다.

      부디 누군가에 도움이 되었으면 합니다.


      끝 -

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      Kyu 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기