<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dong-gwan.log</title>
        <link>https://velog.io/</link>
        <description>안녕하세요. 방문해주셔서 감사합니다.</description>
        <lastBuildDate>Sun, 09 Nov 2025 14:56:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dong-gwan.log</title>
            <url>https://velog.velcdn.com/images/dong-gwan/profile/138dfb8d-4512-4f23-bc0c-c31c71051545/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dong-gwan.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dong-gwan" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Keycloak으로 OIDC SSO 구현하기(with ArgoCD, Grafana, Airflow)]]></title>
            <link>https://velog.io/@dong-gwan/Keycloak%EC%9C%BC%EB%A1%9C-OIDC-SSO-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0with-ArgoCD-Grafana-Airflow</link>
            <guid>https://velog.io/@dong-gwan/Keycloak%EC%9C%BC%EB%A1%9C-OIDC-SSO-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0with-ArgoCD-Grafana-Airflow</guid>
            <pubDate>Sun, 09 Nov 2025 14:56:15 GMT</pubDate>
            <description><![CDATA[<h2 id="배경-상황">배경 상황</h2>
<ul>
<li><p>서비스를 확장 개발하면서 도입되는 OSS가 다양해지면서 아이디/비밀번호 기반의 인증 정보 관리에 다소 어려움을 겪고 있음.</p>
<ul>
<li>ArgoCD * 3, Grafana * 3, Airflow * 3, ...</li>
<li>Dev/Stage/Live 총 3개의 환경이다보니 관리해야 되는 계정 정보가 3배로 증가</li>
<li>추후 ES, Kibana 등 더 도입해야 할 OSS도 남아있는 상황</li>
<li>모든 환경의 아이디/비밀번호를 통일하거나, 공유 계정을 통해 접속하는 것은 장기적으로 지양해야 할 부분이라고 생각됨.
f<h2 id="해결-방안">해결 방안</h2>
</li>
</ul>
</li>
<li><p>Keycloak 도입으로 OIDC 기반의 SSO 구현</p>
</li>
</ul>
<h2 id="ssosingle-sign-on-란">SSO(Single Sign On) 란?</h2>
<ul>
<li>SSO란 사용자가 한번의 로그인으로 여러 서비스에 접근할 수 있는 인증 체계</li>
</ul>
<h2 id="authentication인증-authorization인가">Authentication(인증), Authorization(인가)</h2>
<ul>
<li>Authentication(인증)<ul>
<li>신원을 증명하는 행위</li>
</ul>
</li>
<li>Authorization(인가)<ul>
<li>권한을 부여하거나 권한을 받는 행위나 사실</li>
</ul>
</li>
</ul>
<h2 id="oidcopenid-connect-란">OIDC(OpenID Connect) 란?</h2>
<ul>
<li>OAuth 2.0 기반의 ID 인증 프로토콜<ul>
<li>OAuth(Open Authorization)이란 제3자가 HTTP 서비스에 액세스를 얻기 위한 권한 위임 개방형 표준 프로토콜</li>
<li>OAuth 1.0의 복잡성과 인증 방식을 개선시켜 더 보편화시킨 것이 OAuth 2.0</li>
<li>OAuth 2.0이 Access Token 기반의 권한 위임(인가) 프로토콜이었다면, OIDC는 ID Token 기반의 인증 프로토콜</li>
</ul>
</li>
<li>OAuth 2.0의 시퀀스 다이어그램(Authorization Code 방식)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/a76a13c5-76ea-4b13-ab4f-d54683fa9cbb/image.png" alt=""></p>
<ol>
<li>Resource Owner가 Client에 Resource 요청 &amp; Resource로의 접근 권한 인가</li>
<li>Authorization Server는 사용자 인증 및 Client에게 접근 권한을 위임할 것인지 확인</li>
<li>Authorization Server가 Client로 Authorization Code 발급</li>
<li>Client는 발급받은 Authorization Code로 Authorization Server에게 Access Token 요청</li>
<li>Authorization Server는 Client에게 Access Token 발급</li>
<li>Client는 Access Token을 통해 Resource Server에 접근</li>
<li>Resource Server은 Access Token을 확인 후 Client에게 Resource를 전달</li>
<li>Client는 Resource를 Resource Owner에게 전달</li>
</ol>
<ul>
<li>Resource Owner<ul>
<li>보호된 리소스에 접근 권한을 부여할 수 있는 주체</li>
</ul>
</li>
<li>Resource Server<ul>
<li>보호된 리소스를 호스팅 중인 서버<ul>
<li>Access Token을 사용하여 리소스 요청을 허용하거나 응답할 수 있음.</li>
</ul>
</li>
</ul>
</li>
<li>Client<ul>
<li>Resource Owner를 대신하여 보호된 리소스 요청을 보내는 애플리케이션</li>
</ul>
</li>
<li>Authorization Server<ul>
<li>Resource Owner의 신원이 검증되면, Client로 Access Token을 발급해주는 서버</li>
<li>Authorization Server는 Resource Server와 같을 수도 있고 다를 수도 있음.</li>
</ul>
</li>
<li>OIDC에서는 위 다이어그램에서 Access Token과 함께 사용자 신원 정보가 담긴 ID Token을 함께 발급함.</li>
</ul>
<h2 id="keycloak-이란">Keycloak 이란?</h2>
<ul>
<li>Keycloak은 CNCF에서 관리하는 오픈소스 IAM(Identity and Access Management) 솔루션</li>
<li>애플리케이션은 Keyclaok에게 사용자의 인증/인가 정보를 관리를 위임할 수 있음.</li>
<li>OIDC, SAML 기반의 Single Sign On(SSO) 구현</li>
<li>GitHub, Facebook, Google 등 소셜 로그인 지원</li>
<li>Keycloak DB 외 LDAP, Active Directory 등 외부 ID 저장소와 통합 가능</li>
</ul>
<h3 id="realm">Realm</h3>
<p>애플리케이션, 사용자, 역할 등이 묶여 격리된 환경을 제공하는 논리적 단위</p>
<h3 id="client">Client</h3>
<p>사용자를 대신하여 Keycloak으로 인증/인가를 요청하는 애플리케이션</p>
<h3 id="client-scopes">Client Scopes</h3>
<p>인증/인가에 필요한 정보를 토큰의 각 항목(클레임)에 매핑시키는 규칙들의 그룹</p>
<h2 id="keycloak으로-sso-구현하기with-argocd-grafana-airflow">Keycloak으로 SSO 구현하기(with ArgoCD, Grafana, Airflow)</h2>
<h3 id="1-keycloak-설치">1. Keycloak 설치</h3>
<p><a href="https://www.keycloak.org/guides">https://www.keycloak.org/guides</a></p>
<ul>
<li>가이드 문서를 참고하여 설치</li>
<li>Raw Yaml 그대로 설치해도 되고, Bitnami Helm Chart를 이용해도 무방</li>
<li>Ingress, Gateway API 등 이용해 외부 오픈</li>
<li>설치 이후에는 권장 사항에 따라 새로운 관리자 계정을 만들고 admin 계정을 폐기</li>
</ul>
<h3 id="2-realm-생성">2. Realm 생성</h3>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/7ff60bbc-c064-4d27-a6d1-a6d43cafa0d6/image.png" alt="">
<img src="https://velog.velcdn.com/images/dong-gwan/post/9d2ff8f2-a939-46b0-b100-74c135cfb8d7/image.png" alt=""></p>
<ul>
<li>좌측 <code>Manage realms &gt; Create realm</code> 이후 원하는 Realm Name을 기입하고 <code>Create</code></li>
<li>Relam Name : development</li>
</ul>
<h3 id="3-argocd-grafana-airflow-client-생성">3. ArgoCD, Grafana, Airflow Client 생성</h3>
<ul>
<li><p>Keycloak을 통해 인증/인가 과정을 수행하려는 애플리케이션을 <code>Client</code>로 등록해줘야 한다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/1921dda7-8058-4f12-8e4c-6918eedb117e/image.png" alt=""></p>
</li>
<li><p><code>Clients &gt; Create client</code></p>
</li>
<li><p><strong>ArgoCD</strong></p>
<ul>
<li><strong>Client Type</strong> : OpenID Connect</li>
<li><strong>Client ID</strong> : argocd</li>
<li><strong>Name</strong> : argocd</li>
<li><strong>Client authentication</strong> : On</li>
<li><strong>Authentication flow</strong> : Standard flow</li>
<li><strong>Root URL</strong> : [ArgoCD_Web_URL]</li>
<li><strong>Home URL</strong> : /application</li>
<li><strong>Valid redirect URIs</strong> : ****[ArgoCD_Web_URL]/auth/callback</li>
<li><strong>Web origins</strong> : [ArgoCD_Web_URL]</li>
</ul>
</li>
<li><p><strong>Grafana</strong></p>
<ul>
<li><strong>Client Type</strong> : OpenID Connect</li>
<li><strong>Client ID</strong> : grafana</li>
<li><strong>Name</strong> : grafana</li>
<li><strong>Client authentication</strong> : On</li>
<li><strong>Authentication flow</strong> : Standard flow</li>
<li><strong>Root URL</strong> : [Grafana_Web_URL]</li>
<li><strong>Home URL</strong> : [Grafana_Web_URL]</li>
<li><strong>Valid redirect URIs</strong> : ****[Grafana_Web_URL]/login/generic_oauth</li>
<li><strong>Web origins</strong> : [Grafana_Web_URL]</li>
</ul>
</li>
<li><p><strong>Airflow</strong></p>
<ul>
<li><strong>Client Type</strong> : OpenID Connect</li>
<li><strong>Client ID</strong> : airflow</li>
<li><strong>Name</strong> : airflow</li>
<li><strong>Client authentication</strong> : On</li>
<li><strong>Authentication flow</strong> : Standard flow</li>
<li><strong>Root URL</strong> : [Airflow_Web_URL]</li>
<li><strong>Home URL</strong> : [Airflow_Web_URL]</li>
<li><strong>Valid redirect URIs</strong> : ****[Airflow_Web_URL]/oauth-authorized/keycloak</li>
<li><strong>Web origins</strong> : [Airflow_Web_URL]</li>
</ul>
</li>
</ul>
<h3 id="4-admins-group-생성">4. Admins Group 생성</h3>
<ul>
<li>ArgoCD, Grafana, Airflow 공식 문서에서 권한을 할당하기 위한 매핑에 필요한 토큰 클레임이 각기 다르게 정의되어있기 때문에, 여기서는 <code>Admins</code> 라는 <code>Group</code>에 속해있는 사용자에게 각 서비스의 관리자 권한을 부여하도록 한다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/ffb6f0eb-181d-4c3e-ba9b-0ef3d9643cf4/image.png" alt=""></li>
</ul>
<ul>
<li><code>Groups &gt; Create group</code></li>
<li><strong>Name</strong> : Admins</li>
</ul>
<h3 id="5-client-scopes-생성">5. Client Scopes 생성</h3>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/8b9457ec-1d8c-4348-ac62-08540fbb0634/image.png" alt=""></p>
<ul>
<li><code>Client scopes &gt; Create client scope</code></li>
<li><strong>Name</strong> : groups</li>
<li><strong>Type</strong> : Default<ul>
<li>신규 <code>Client</code> 생성 시에 해당 <code>scope</code>를 <code>Default</code>로 설정할지, <code>Optional</code>로 설정할지 결정한다</li>
</ul>
</li>
</ul>
<h3 id="6-mapper-설정">6. Mapper 설정</h3>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/e2435761-3d9a-4c8f-80fd-b3681a7adc8d/image.png" alt="">
<img src="https://velog.velcdn.com/images/dong-gwan/post/aeac00a3-d0ff-402f-b199-720ba6759ba5/image.png" alt="">
<img src="https://velog.velcdn.com/images/dong-gwan/post/e40fdfad-018e-4546-abcd-4fa07d39ffdf/image.png" alt=""></p>
<ul>
<li><code>Mapper</code>란 토큰의 클레임과 정보를 매핑시켜주는 것</li>
<li><code>Mappers &gt; Configure a new mapper</code></li>
<li><strong>Mapper Type</strong> : Group Membership<ul>
<li>Group Membership 이란 사용자가 소속된 Group을 클레임으로 매핑시키는 것</li>
</ul>
</li>
<li><strong>Name</strong> : groups</li>
<li><strong>Token Claim Name</strong> : groups</li>
<li><strong>Full group path</strong> : Off</li>
<li><strong>Add to ID token</strong> : On</li>
<li><strong>Add to access token</strong> : On</li>
<li><strong>Add to userinfo</strong> : On</li>
<li><strong>Add to token introspection</strong> : On</li>
<li>Token Claim Name은 사용자의 그룹이 실제로 토큰에 담길 클레임명을 정하는 것이다.</li>
</ul>
<h3 id="7-argocd-secret-configmap-수정">7. ArgoCD Secret, ConfigMap 수정</h3>
<ul>
<li><a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/keycloak/">https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/keycloak/</a></li>
</ul>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/82819f42-16f8-49e8-a2d0-35919425e361/image.png" alt=""></p>
<ul>
<li><p><code>Client Secret</code> 을 복사한 뒤 Base64로 인코딩하여 <code>argocd-secret</code>에 <code>oidc.keycloak.clientSecret</code> 값으로 기입</p>
</li>
<li><p><code>argocd-cm</code> 에 아래와 같이 <code>oidc.config</code> 추가</p>
</li>
<li><p><code>argocd-rbac-cm</code>에 아래와 같이 <code>policy.csv</code> 추가</p>
</li>
<li><p>수정 후 <code>argocd-server</code> Restart</p>
<pre><code class="language-yaml">  [dgyoon@kube-master ~]# echo -n &#39;&lt;Client Secret&gt;&#39; | base64
  **&lt;base64-encoded-value&gt;**

  [dgyoon@kube-master ~]# kubectl edit -n argocd secret argocd-secret
  ===
  apiVersion: v1
  kind: Secret
  metadata:
    labels:
      app.kubernetes.io/name: argocd-secret
      app.kubernetes.io/part-of: argocd
    name: argocd-secret
    namespace: argocd
  type: Opaque
  data:
      ...
    oidc.keycloak.clientSecret: **&lt;base64-encoded-value&gt;**
      ...
  ===

  [dgyoon@kube-master ~]# kubectl edit -n argocd cm argocd-cm
  ===
  apiVersion: v1
  kind: ConfigMap
  metadata:
    labels:
      app.kubernetes.io/name: argocd-cm
      app.kubernetes.io/part-of: argocd
    name: argocd-cm
    namespace: argocd
  data:
    ...
    oidc.config: |
      name: keycloak
      issuer: https://&lt;KEYCLOAK WEB URL&gt;/realms/&lt;REALM NAME&gt;
      clientID: argocd
      clientSecret: $oidc.keycloak.clientSecret
      requestedScopes: [&quot;openid&quot;,&quot;profile&quot;,&quot;email&quot;,&quot;groups&quot;]
    ...
  ===

  [dgyoon@kube-master ~]# kubectl edit -n argocd cm argocd-rbac-cm
  ===
  apiVersion: v1
  kind: ConfigMap
  metadata:
    labels:
      app.kubernetes.io/name: argocd-rbac-cm
      app.kubernetes.io/part-of: argocd
    name: argocd-rbac-cm
    namespace: argocd
  data:
    policy.csv: |
      g, Admins, role:admin
  ===

  [dgyoon@kube-master ~]# kubectl rollout restart deployment argocd-server -n argocd</code></pre>
</li>
</ul>
<h3 id="8-grafana-grafanaini-수정">8. Grafana grafana.ini 수정</h3>
<ul>
<li><a href="https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/keycloak/">https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/keycloak/</a></li>
<li>Helm Chart로 설치한 Grafana라면 <code>values.yaml</code> 수정</li>
<li><code>role_attribute_path</code> 에 정의된 대로 <code>groups</code> 클레임에 <code>Admins</code>가 포함되어 있다면 Admin 권한 할당</li>
</ul>
<pre><code class="language-yaml">grafana.ini:
  auth.generic_oauth:
    enabled: true
    name: Keycloak-OAuth
    allow_sign_up: true
    client_id: grafana
    client_secret: &lt;GRAFANA_CLIENT_SECRET&gt;
    scopes: openid email profile groups
    email_attribute_path: email
    login_attribute_path: username
    name_attribute_path: full_name
    auth_url: https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/protocol/openid-connect/auth
    token_url: https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/protocol/openid-connect/token
    api_url: https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/protocol/openid-connect/userinfo
    role_attribute_path: contains(groups[*], &#39;Admins&#39;) &amp;&amp; &#39;Admin&#39; || contains(groups[*], &#39;editor&#39;) &amp;&amp; &#39;Editor&#39; || &#39;Viewer&#39;</code></pre>
<h3 id="9-keycloak-webserverconfig-수정">9. Keycloak webserverconfig 수정</h3>
<ul>
<li><a href="https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/webserver-authentication.html#example-using-team-based-authorization-with-keycloak">https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/webserver-authentication.html#example-using-team-based-authorization-with-keycloak</a></li>
<li>Helm Chart로 설치한 Airflow라면 <code>values.yaml</code> 수정</li>
</ul>
<pre><code class="language-yaml">web:
  webserverConfig:
    enabled: true
    stringOverride: |
        import logging
        from base64 import b64decode

        import jwt
        import requests
        from cryptography.hazmat.primitives import serialization
        from flask_appbuilder.security.manager import AUTH_OAUTH

        from airflow.www.security import AirflowSecurityManager

        log = logging.getLogger(__name__)

        AUTH_TYPE = AUTH_OAUTH
        AUTH_USER_REGISTRATION = True
        AUTH_ROLES_SYNC_AT_LOGIN = True
        AUTH_USER_REGISTRATION_ROLE = &quot;Viewer&quot;
        OIDC_ISSUER = &quot;https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;&quot;

        # Make sure you create these role on Keycloak
        AUTH_ROLES_MAPPING = {
            &quot;Viewer&quot;: [&quot;Viewer&quot;],
            &quot;Admins&quot;: [&quot;Admin&quot;],
            &quot;User&quot;: [&quot;User&quot;],
            &quot;Public&quot;: [&quot;Public&quot;],
            &quot;Op&quot;: [&quot;Op&quot;],
        }

        OAUTH_PROVIDERS = [
            {
                &quot;name&quot;: &quot;keycloak&quot;,
                &quot;icon&quot;: &quot;fa-key&quot;,
                &quot;token_key&quot;: &quot;access_token&quot;,
                &quot;remote_app&quot;: {
                    &quot;client_id&quot;: &quot;airflow&quot;,
                    &quot;client_secret&quot;: &quot;&lt;AIRFLOW_CLIENT_SECRET&gt;&quot;,
                    &quot;server_metadata_url&quot;: &quot;https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/.well-known/openid-configuration&quot;,
                    &quot;api_base_url&quot;: &quot;https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/protocol/openid-connect&quot;,
                    &quot;client_kwargs&quot;: {&quot;scope&quot;: &quot;email profile groups&quot;},
                    &quot;access_token_url&quot;: &quot;https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/protocol/openid-connect/token&quot;,
                    &quot;authorize_url&quot;: &quot;https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/protocol/openid-connect/auth&quot;,
                    &quot;request_token_url&quot;: None,
                },
            }
        ]

        # Fetch public key
        req = requests.get(OIDC_ISSUER)
        key_der_base64 = req.json()[&quot;public_key&quot;]
        key_der = b64decode(key_der_base64.encode())
        public_key = serialization.load_der_public_key(key_der)

        class CustomSecurityManager(AirflowSecurityManager):
            def get_oauth_user_info(self, provider, response):
                if provider == &quot;keycloak&quot;:
                    token = response[&quot;access_token&quot;]
                    me = jwt.decode(token, public_key, algorithms=[&quot;HS256&quot;, &quot;RS256&quot;], audience=&quot;account&quot;)

                    groups = me.get(&quot;groups&quot;, [])

                    log.info(&quot;groups: {0}&quot;.format(groups))

                    if not groups:
                        groups = [&quot;Viewer&quot;]

                    userinfo = {
                        &quot;username&quot;: me.get(&quot;preferred_username&quot;),
                        &quot;email&quot;: me.get(&quot;email&quot;),
                        &quot;first_name&quot;: me.get(&quot;given_name&quot;),
                        &quot;last_name&quot;: me.get(&quot;family_name&quot;),
                        &quot;role_keys&quot;: groups,
                    }

                    log.info(&quot;user info: {0}&quot;.format(userinfo))

                    return userinfo
                else:
                    return {}

        # Make sure to replace this with your own implementation of AirflowSecurityManager class
        SECURITY_MANAGER_CLASS = CustomSecurityManager</code></pre>
<h3 id="10-argocd-grafana-airflow-sso-구현">10. ArgoCD, Grafana, Airflow SSO 구현</h3>
<ul>
<li><code>Sign in via Keycloak</code> 버튼을 통해 아이디/비밀번호를 입력해 로그인하면 다른 애플리케이션에서는 버튼 클릭만으로 로그인 가능</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/4e02b1ed-b87c-4a5c-85d9-f3bfb57c5acf/image.png" alt="">
<img src="https://velog.velcdn.com/images/dong-gwan/post/98cf2f12-29f2-4927-bdbf-32d1bece8b7e/image.png" alt="">
<img src="https://velog.velcdn.com/images/dong-gwan/post/34d2ff78-0930-43a8-b646-9b3f1606a8ae/image.png" alt=""></p>
<h3 id="인증인가-flowargocd"><strong>인증/인가 Flow(ArgoCD)</strong></h3>
<ol>
<li><p>사용자가 <code>Login Via Keycloak</code> 버튼을 클릭하면, Keycloak Login 화면으로 리디렉션시키며, URL 상의 <code>rediect_uri</code> 에 클라이언트의 주소를 남김</p>
<ol>
<li><p>Keycloak에 등록된 Client ID와 요청할 Scope 포함</p>
<pre><code class="language-markup">https://&lt;KEYCLOAK_WEB_URL&gt;/realms/&lt;REALM_NAME&gt;/protocol/openid-connect/auth?**client_id=argocd-dev**&amp;**redirect_uri=https%3A%2F%&lt;**ARGOCD_WEB_URL**&gt;%2Fauth%2Fcallback&amp;response_type=code&amp;scope=openid+profile+email+groups&amp;state=rNsquawYZwluTEsfkbNemuld**</code></pre>
</li>
</ol>
</li>
<li><p>사용자가 Keycloak Login 화면에서 로그인에 성공한다면, <code>redirect_uri</code>에 <code>Authorization Code</code> 를 담아 다시 리디렉션시킴.</p>
<pre><code class="language-markup"> https://&lt;ARGOCD_WEB_URL&gt;/auth/callback?state=rNsquawYZwluTEsfkbNemuld&amp;session_state=ef7339c1-5084-413b-9df3-c40e462853e2&amp;iss=https%3A%2F%2Fkeycloak.testworks.dev%2Frealms%2Fddock-ddock&amp;**code=4c1d60fa-9af3-4941-8346-828ccb754fdf.ef7339c1-5084-413b-9df3-c40e462853e2.5d7e0d08-1dcc-4735-aa88-1461f93e18d2**</code></pre>
</li>
<li><p><code>/auth/callback</code> API가 호출되어 <code>Authorization Code</code>를 Keycloak에서 Access Token과 ID Token으로 Exchange</p>
<ol>
<li><code>POST /realms/ddock-ddock/protocol/openid-connect/token</code> </li>
<li>Access Token은 인가, ID Token은 인증</li>
<li>Access Token, ID Token을 바로 발급받지 않고, 굳이 <code>Authorization Code</code>를 통해 발급받는 이유?<ol>
<li><code>redirect_uri</code>로 전달하기 때문에 Access Token이 탈취될 위험이 있음</li>
<li>토큰이 사용자의 브라우저에 저장되지 않고, 백엔드 서버 간 API 통신으로 더 안전한 통신 가능</li>
<li>토큰 요청 시 Keycloak에 등록된 <code>Client Secret</code>이 필요하기 때문에 <code>Authorization Code</code> 가 탈취당하더라도 토큰을 발급받을 수 없음(백엔드 서버만 소유)</li>
</ol>
</li>
</ol>
</li>
<li><p>ArgoCD는 ID Token을 기반으로 <code>argocd.token</code> 세션 토큰을 생성하여 사용자 브라우저에 쿠키로 저장</p>
<ol>
<li>ArgoCD는 자체적인 권한 제어 방식(RBAC)이 있기 때문에, Access Token을 인가에 사용하지는 않고, Keycloak UserInfo 엔드포인트를 호출할 때 사용함.</li>
</ol>
</li>
</ol>
<h2 id="참조">참조</h2>
<ul>
<li><a href="https://www.keycloak.org/guides">https://www.keycloak.org/guides</a></li>
<li><a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/keycloak/">https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/keycloak/</a></li>
<li><a href="https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/keycloak/">https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/keycloak/</a></li>
<li><a href="https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/webserver-authentication.html#example-using-team-based-authorization-with-keycloak">https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/webserver-authentication.html#example-using-team-based-authorization-with-keycloak</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Harbor로 내부 컨테이너 레지스트리 구축하기]]></title>
            <link>https://velog.io/@dong-gwan/Harbor</link>
            <guid>https://velog.io/@dong-gwan/Harbor</guid>
            <pubDate>Wed, 25 Jun 2025 08:00:21 GMT</pubDate>
            <description><![CDATA[<p>Harbor란 CNCF(Cloud Native Computing Foundation)에서 관리되는 오픈소스 컨테이너 이미지 레지스트리이다.</p>
<h2 id="harbor를-써야-하는-이유">Harbor를 써야 하는 이유</h2>
<p>AWS EKS를 쓴다면 AWS ECR(Elastic Container Registry), GCP GKE를 쓴다면 GCP Artifact Registry를 사용하면 되는데, 왜 굳이 Harbor를 써야 하는걸까?</p>
<p>클라우드 컨테이너 플랫폼 서비스를 사용한다면, 일반적으로 해당 CSP 사의 클라우드 이미지 저장소를 사용하면 된다(비용도 저렴함).</p>
<p>아래의 경우 Harbor 도입을 고민해볼 수 있다.</p>
<h3 id="외부-이미지-저장소를-사용할-수-없는-경우">외부 이미지 저장소를 사용할 수 없는 경우</h3>
<p>개발 환경이 내부 인트라넷망으로 구성되어 외부 인터넷망과 통신할 수 없어 Docker Hub는 물론 AWS ECR, GCP Artifact Registry도 사용할 수 없는 경우라면 Harbor를 자체적으로 구성해 사용할 수 있다.</p>
<h3 id="외부-이미지-저장소-인증-토큰을-주기적으로-갱신시켜야-하는-경우">외부 이미지 저장소 인증 토큰을 주기적으로 갱신시켜야 하는 경우</h3>
<p>AWS ECR의 경우 기본적으로 레지스트리 인증 토큰이 12시간만 유효하다.</p>
<p>AWS EKS 내부에서는 자체적인 절차를 통해 토큰을 갱신시킬 필요가 없지만, 다른 계정에 있는 이미지를 사용해야 하거나, 개발 환경이 아예 AWS와 분리되어있다면 12시간마다 토큰을 갱신시켜줘야 이미지를 Pull 할 수 있다.</p>
<blockquote>
<p><em>The generated token is valid for 12 hours, which means developers running and managing container images have to re-authenticate every 12 hours manually, or script it to generate a new token, which can be somewhat cumbersome in a CI/CD environment.</em></p>
</blockquote>
<p>이 경우 Harbor를 구축해 Harbor의 계정 만료 시간 설정을 통해 원하는 주기를 조절하여 또는 무기한으로 인증하여 사용할 수 있다.</p>
<h3 id="외부-이미지-저장소-비용을-절약하려는-경우">외부 이미지 저장소 비용을 절약하려는 경우</h3>
<p>AWS ECR, GCP Artifact Registry 등 컨테이너 이미지 저장소 비용은 일반적으로 높지 않다.</p>
<p>그럼에도 불구하고 저장소의 사용량이 많아 비용이 높게 책정되는 경우도 있을 것이고, 특히 이미지 취약점 스캔 비용이 높게 책정될 수 있다.</p>
<p>Harbor는 Trivy(기본)을 통해 이미지 취약점 스캔을 지원하기 때문에 해당 비용을 절약하기 위해 사용할 수 있다.</p>
<p>이 외에도 다양한 목적을 가지고 Harbor를 자체 구성하여 사용할 수 있을 것이다.</p>
<p>필자의 경우 <strong>외부 이미지 저장소 인증 토큰을 주기적으로 갱신시켜야 하는 경우</strong> 때문에 개발 환경(온-프레미스 쿠버네티스 클러스터)에서 사용하는 저장소를 AWS ECR에서 Harbor로 변경하여 구성해놓았다.</p>
<p>“갱신 그거 그냥 주기적으로 자동화시켜놓으면 되는거 아닌가요?” 라는 의문이 들 수 있지만, 필자는 관리 포인트를 가급적 줄이고 싶었고(갱신 배치 설정 및 수행 여부 확인 등), 내부 환경에 필요한 이미지를 굳이 외부에 저장할 이유는 없다고 생각했다.</p>
<p>AWS EKS 환경에서는 AWS ECR을 사용 중이며, GCP GKE를 사용했을 때에도 GCP Artifact Registry를 사용했다.</p>
<h2 id="설치--사용">설치 &amp; 사용</h2>
<p>Kubernetes 환경에 설치하고 이를 사용해보는 간단한 예제이다.</p>
<p><a href="https://goharbor.io/docs/">Harbor 공식 문서</a>를 기반하여 작성하였고 Kubernetes 클러스터에 배포할 것이기에 Helm을 통해 설치하였다.</p>
<pre><code class="language-bash">helm repo add harbor https://helm.goharbor.io
helm show values harbor/harbor &gt; values.yaml
vi values.yaml # 본인 환경에 맞게 수정
helm install --create-namespace harbor -n harbor harbor/harbor -f values.yaml</code></pre>
<pre><code class="language-bash">[root@kube-master ~]# k get po -n harbor
NAME                                READY   STATUS    RESTARTS   AGE
harbor-core-86d966f68-pcxwr         1/1     Running   0          8d
harbor-database-0                   1/1     Running   0          8d
harbor-jobservice-975bf4887-mwsm6   1/1     Running   0          8d
harbor-nginx-cc75d9994-mzqtp        1/1     Running   0          8d
harbor-portal-fffc56c6-4fx77        1/1     Running   0          8d
harbor-redis-0                      1/1     Running   0          8d
harbor-registry-559f784dbd-zd8nc    2/2     Running   0          8d
harbor-trivy-0                      1/1     Running   0          8d</code></pre>
<p>배포가 완료되었으면 harbor-nginx Deployment를 외부에 노출시켜 접속하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/2b36f7dc-ac49-4fe4-8179-73512d6d10f9/image.png" alt=""></p>
<p>외부로 노출시키는 방법은 Ingress, Load Balancer, NodePort 중 자유롭게 선택하면 되고 중요한 점은 Harbor는 TLS 설정없이(http://….) 브라우저에서 로그인을 시도할 시 <code>CSRF token invalid</code> 에러를 뱉으며 로그인이 되지 않는다.</p>
<p>때문에 도메인을 설정해주고 인증서 발급 후 TLS 설정을 해줘야 로그인이 가능하다.</p>
<p>필자는 Gateway API를 사용 중인데 도메인 설정 후 Cert-Manager를 통해 자동으로 인증서가 발급되어서 사용했고, 인증서 설정 방법은 여러가지이기 때문에(Ingress TLS, Nginx Certbot, …) 자유롭게 설정하면 된다.</p>
<p>웹 접속 후 초기 계정 정보를 입력하고 (admin / Harbor12345) 로그인하면 아래와 같이 홈 화면이 보인다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/db3fbfbf-02db-48a6-bae7-467cd3f995e9/image.png" alt=""></p>
<p>Projects &gt; +NEW PROJECT 를 통해 프로젝트를 생성하여 이미지를 저장하면 되고 <code>docker build</code> 시 <code>[HARBOR_URL]/[PROJECT_NAME]/[IMAGE_NAME]:[TAG]</code>로 이미지를 생성하면 된다.</p>
<p>필자는 이미 사용 중이기 때문에 Projects 여러가지 존재한다.</p>
<p>Harbor에 이미지를 Push/Pull 하는 것은 User 또는 Robot Account를 만들어 수행하면 된다.</p>
<p>User는 웹에 접속할 수 있는 로그인 계정이고, Robot Account는 웹에 로그인할 필요없이 권한과 인증 정보만 필요할 때 사용하는 계정이다.</p>
<p>주로 CI/CD 스크립트에 Robot Account를 사용하면 된다.</p>
<p>Administration &gt; Robot Accounts &gt; + NEW ROBOT ACCOUNT 를 통해 계정명과 권한을 설정해주면 해당 계정에 인증할 때 사용하는 시크릿이 발급되니 저장해두고 다음 과정에 사용하면 된다.</p>
<pre><code class="language-bash">docker login -u robot$[ROBOT_ACCOUNT_NAME] -p [ROBOT_ACCOUNT_SECRET] [HARBOR_URL]</code></pre>
<p>해당 인증 후 <code>docker push, docker pull</code> 을 통해 성공적으로 이미지를 저장하고 가져올 수 있다.</p>
<h3 id="kubernetes-imagepullsecret">Kubernetes ImagePullSecret</h3>
<p>Kubernetes 클러스터에 생성할 이미지를 Harbor에 저장했다면, 적절한 인증 정보를 기입해주어야 해당 이미지를 Pull 하여 Pod가 생성될 것이다.</p>
<pre><code class="language-bash">kubectl create secret docker-registry [SECRET_NAME] \
--docker-username=[ROBOT_ACCOUNT_NAME] \
--docker-password=[ROBOT_ACCOUNT_SECRET] \
--docker-server=[HARBOR_URL]</code></pre>
<p>그 후 생성된 Secret을 Deployment Spec에 imagePullSecret으로 명시하거나 Service Accunt imagePullSecrets에 설정해주면 정상적으로 Pod를 생성할 수 있다.</p>
<h3 id="취약점-스캔">취약점 스캔</h3>
<p>생성한 Project의 Configuration에서 <strong>Vulnerability scanning - [Automatically scan images on push]</strong> 을 통해 이미지를 Push 할 때마다 취약점 스캔을 할지 정할 수 있으며, 업로드된 이미지 하나를 클릭해 조회해보면 어떤 취약점이 발견되고 어느 버전으로 업데이트 해야 하는지 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/318eb110-af9d-4096-8159-c218de68ca86/image.png" alt=""></p>
<p>그 외에도 다른 레지스트리와 연동해 Replication 하는 기능이나, 이미지 삭제 정책, Webhook을 설정할 수 있으니 공식 문서를 참고해 필요한 기능을 사용해보면 좋다.</p>
<h3 id="참고">참고</h3>
<ul>
<li><a href="https://aws.amazon.com/ko/blogs/compute/authenticating-amazon-ecr-repositories-for-docker-cli-with-credential-helper/"><em>https://aws.amazon.com/ko/blogs/compute/authenticating-amazon-ecr-repositories-for-docker-cli-with-credential-helper/</em></a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis Persistence와 운영 모드]]></title>
            <link>https://velog.io/@dong-gwan/Redis-%EB%AC%B8%EC%A0%9C-%EB%B0%9C%EC%83%9D%EA%B3%BC-%EC%9A%B4%EC%98%81-%EB%AA%A8%EB%93%9C</link>
            <guid>https://velog.io/@dong-gwan/Redis-%EB%AC%B8%EC%A0%9C-%EB%B0%9C%EC%83%9D%EA%B3%BC-%EC%9A%B4%EC%98%81-%EB%AA%A8%EB%93%9C</guid>
            <pubDate>Mon, 14 Apr 2025 09:45:42 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>로그인 시 사용 및 발급되는 Access Token, Refresh Token 저장에 Redis를 사용하고 있었으며, Google Cloud의 관리형 쿠버네티스 서비스 GKE에서 Deployment로 배포하여 사용 중이었다.</p>
<p>문제는 Redis의 Persistence 설정을 주지 않아 클라우드 벤더사(Google)의 노드 관리 작업으로 인해 Pod가 노드에서 추출될 때 Redis 메모리의 데이터가 소실되었다.(사실 당연히 발생할 수 밖에 없는 문제였다.)</p>
<p>문제는 JWT 형식의 Access Token의 유효 기한이 30분이라, 최대 30분 뒤에는 모든 클라이언트의 로그인이 풀린다는 점이었다.</p>
<p>위 문제 상황으로 인해 Redis Persistence는 필수로 설정하고, Cluster 단위의 설정까지 필요한지 확인해보기로 했다.</p>
<h2 id="persistence">Persistence</h2>
<h3 id="rdbredis-database">RDB(Redis Database)</h3>
<p>일정 주기마다 메모리 내의 데이터셋을 스냅샷으로 백업하는 방식이다.</p>
<p><strong>장점</strong></p>
<ul>
<li>스냅샷 그대로 로드하면 되므로 복구 속도가 빠르다.</li>
<li>주기적으로 백업하므로 성능 부하가 생길 가능성이 낮다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>마지막 스냅샷 이후의 변경 내용은 소실된다.</li>
</ul>
<h3 id="aofappend-only-file">AOF(Append Only File)</h3>
<p>모든 쓰기 작업 명령을 파일로 저장하는 백업 방식이다.</p>
<p><strong>장점</strong></p>
<ul>
<li>소실되는 데이터가 존재할 가능성이 낮다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>로그에 적힌 모든 명령어를 실행해야 하므로 복구 속도가 느리다.</li>
<li>명령어를 기록해야 하므로 I/O 작업이 증가한다.</li>
</ul>
<blockquote>
<p>RDB, AOF 중 하나만 선택해야 하는게 아니고 둘 다 사용할 수도 있다.</p>
</blockquote>
<h2 id="standalone">StandAlone</h2>
<p>일반적으로 Redis Image를 그대로 Deployment로 배포하는 경우 StandAlone으로 작동하며, Redis 인스턴스를 하나만 사용해 Write, Read가 같은 단일 엔드포인트를 사용하게 된다.</p>
<p><strong>장점</strong></p>
<ul>
<li>단일 인스턴스이기 때문에 관리가 쉽다.</li>
<li>읽기, 쓰기 엔드포인트가 하나기 때문에 클라이언트 연결도 간편하다.</li>
<li>동기화 작업이 없기 때문에 정합성 문제 역시 없다.</li>
<li>소모 리소스가 적고 복제본 간 통신이 필요없다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>단일 인스턴스이기 때문에 가용성이 낮다.</li>
<li>쓰기와 읽기가 같은 인스턴스에서 작동하기 때문에 과부하가 발생하기 쉽다.</li>
</ul>
<h2 id="replication">Replication</h2>
<p>Master 인스턴스와 그 복제본인 Replica 인스턴스를 분리하여 읽기 전용으로 사용하는 운영 모드이다.</p>
<p>읽기/쓰기 두 개의 엔드포인트를 두어 쓰기 작업과 읽기 작업의 트래픽 분리가 가능하다.</p>
<h3 id="sentinel">Sentinel</h3>
<ul>
<li>Master 인스턴스에 재해 발생 시 자동으로 Failover 하기 위해 존재하는 서버</li>
<li>Sentinel 사용 시 Failover 이후 Master 인스턴스로 전환하기 위해 클라이언트 측 작업이 필요하다.   <pre><code>RedisClient client = RedisClient.create();
    RedisURI uri = RedisURI.Builder
        .sentinel(&quot;sentinel-host&quot;, &quot;mymaster&quot;)
        .withSentinel(&quot;sentinel-host2&quot;)
        .withSentinel(&quot;sentinel-host3&quot;)
        .build();
 StatefulRedisConnection&lt;String, String&gt; conn = client.connect(uri);</code></pre></li>
</ul>
<p><strong>장점</strong></p>
<ul>
<li>쓰기와 읽기의 엔드포인트가 나뉘어 부하 분산이 가능함.</li>
<li>Sentinel을 통해 가용성을 챙길 수 있음.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>동기화 과정에서 데이터가 서로 맞지 않는 정합성 문제가 발생할 가능성도 있음.</li>
</ul>
<p>Failover도 시간이 소요되기 때문에 완전한 무중단 운영은 불가능하다.</p>
<h2 id="cluster">Cluster</h2>
<p>데이터를 여러 노드에 분산 저장(샤딩)하면서, 다중 Master-Replica 구조로 고가용성까지 지원하는 Redis의 확장형 모드이다.</p>
<p><strong>장점</strong></p>
<ul>
<li>각 Master에 여러 Replica가 존재해 고가용성을 보장한다.</li>
<li>데이터 저장과 트래픽이 분산 처리된다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>설정과 운영이 복잡하다.</li>
<li>인스턴스가 많으니 소모되는 리소스가 높다.</li>
</ul>
<h2 id="선택한-방식">선택한 방식</h2>
<p>아래의 이유로 Redis StandAlone에 Persistence는 AOF + RDB로 대응하기로 결정했다.</p>
<ol>
<li>Redis 활용 범위가 Access/Refresh Token에 한정되어 트래픽 부담이 크지 않았다.</li>
<li>클라우드 벤더의 작업을 제외하면 Redis 자체 장애가 없었다.</li>
<li>클라이언트 구성 변경을 최소화하고 싶었다.</li>
</ol>
<p>추후 트래픽 증가나 다른 데이터 저장 용도로 Redis를 확장하게 된다면 Replication 또는 Cluster로 전환을 고려해야할 듯 싶다.</p>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/">https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/</a></li>
<li><a href="http://redisgate.kr/redis/configuration/replication.php">http://redisgate.kr/redis/configuration/replication.php</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kubernetes로 알아보는 X.509, mTLS]]></title>
            <link>https://velog.io/@dong-gwan/Kubernetes%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-X.509-mTLS</link>
            <guid>https://velog.io/@dong-gwan/Kubernetes%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-X.509-mTLS</guid>
            <pubDate>Fri, 28 Mar 2025 07:23:13 GMT</pubDate>
            <description><![CDATA[<h3 id="시작하며">시작하며</h3>
<p>오늘은 중요하지만 맨날 까먹는 X.509와 mTLS에 대해서 정리하려고 한다.</p>
<p>사실 X.509와 TLS, mTLS는 엔지니어로 일하다보면, 상당히 많이 듣게 되는데 정작 인증서 발급 과정을 자세히 살펴보지 않거나, 이미 구현되어 있는 경우 어떻게 동작하는지 배우더라도 금방 잊어버리기 쉽다.</p>
<p>필자 또한 마찬가지이며, 이번엔 Kubernetes와 연동해 기억해보려고 한다.</p>
<p>Kubernetes Control Plane의 컴포넌트들은 API Server, Schduler, Controller Manager, ETCD가 존재한다.</p>
<p>이 컴포넌트들은 API Server를 중심으로 상호 작용하며 Kubernetes 클러스터를 유지시킨다.</p>
<p>각 컴포넌트들은 클러스터의 핵심 부분이기 때문에 신뢰하지 못할만한 소스로부터 오는 요청을 막아야 할 필요가 있다.</p>
<p>여기에 X.509 인증서를 통한 인증 방식이 사용되고 있다.</p>
<h3 id="x509">X.509</h3>
<p>X.509는 <strong>공개 키 인증서(Public Key Certificate)의 표준 형식</strong>을 정의하는 국제 표준(ITU-T X.509)이고, X.509 형식의 인증서를 제시함으로써 내 신원을 검증받는 것이다.</p>
<p>X.509 인증서를 발급받기 위한 요청자는 신뢰할만한 인증 단체(CA)로부터 인증서를 발급받기 위해 자신의 신원 정보와 공개 키를 포함한 인증서 서명 요청(CSR)을 생성해 CA에 요청하면, CA의 개인키로 서명을 받고 인증서를 발급 받는다.</p>
<p>해당 인증서를 발급받고 통신에 사용할 때엔, 인증서를 발급한 CA의 인증서가 있어야 CA의 공개 키를 추출해 해당 인증서가 CA의 개인 키로 서명을 받았다는 것을 검증할 수 있다.
주로 SSL/TLS 보안 통신에 X.509 기반 인증서가 사용되며, Kubernetes Control Plane 내 컴포넌트 간의 mTLS 통신에 사용되고 있다.</p>
<h3 id="tlsmtls">TLS/mTLS</h3>
<p>TLS란 클라이언트와 서버 간 통신 데이터를 암호화해주는 보안 프로토콜인데, 일반적으로 서버 측에서 X.509 인증서를 발급받고 해당 인증서로 TLS 통신을 구현해 클라이언트와의 통신이 안전함을 증명한다.</p>
<p>mTLS란 서버뿐만이 아닌, 클라이언트도 인증서를 통해 자신이 안전한 사용자임을 증명하고, 서버 측에서는 서버 측에서 알고 있는 인증서를 가진 요청만 허용해주어 더 안전한 통신을 가능하게 해주는 상호 인증 방식이다.</p>
<p>Kubernetes Control Plane의 컴포넌트들은 X.509 인증서로 상호 인증(mTLS)된 클라이언트-서버 간 통신만 허용하고 있다.</p>
<p>클라이언트는 서버 측에 요청을 보낼 때 검증된 인증서를 같이 보내고, 서버에서는 이를 검증하여 통과된 사용자에게 응답을 주는 방식이고, 이 때 서버의 인증서도 클라이언트 측에서 검증된다.</p>
<p>컨트롤 플레인 내 통신에 사용되는 모든 인증서들은 <code>/etc/kubernetes</code>와 <code>/etc/kubernetes/pki</code> 경로에 있다.</p>
<h3 id="kubernetes-control-plane">Kubernetes Control Plane</h3>
<p>Control Plane 컴포넌트들은 <code>/etc/kubernetes/manifest</code> 경로에 정의된대로 Static Pod로 실행되는데, <code>kube-apiserver.yaml</code>에는 다른 컴포넌트와의 통신에 필요한 인증서와 키를 명시할 수 있다.</p>
<pre><code class="language-yaml"> ...
 spec:
  containers:
  - command:
    - kube-apiserver
    - --advertise-address=172.30.1.90
    - --allow-privileged=true
    - --authorization-mode=Node,RBAC
    - --client-ca-file=/etc/kubernetes/pki/ca.crt
    - --enable-admission-plugins=NodeRestriction
    - --enable-bootstrap-token-auth=true
    - **--etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
    - --etcd-certfile=/etc/kubernetes/pki/apiserver-etcd-client.crt
    - --etcd-keyfile=/etc/kubernetes/pki/apiserver-etcd-client.key**
    - --etcd-servers=https://127.0.0.1:2379
    - --**kubelet-client-certificate=/etc/kubernetes/pki/apiserver-kubelet-client.crt
    - --kubelet-client-key=/etc/kubernetes/pki/apiserver-kubelet-client.key**
    - --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname
    - --proxy-client-cert-file=/etc/kubernetes/pki/front-proxy-client.crt
    - --proxy-client-key-file=/etc/kubernetes/pki/front-proxy-client.key
    - --requestheader-allowed-names=front-proxy-client
    - --requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt
    - --requestheader-extra-headers-prefix=X-Remote-Extra-
    - --requestheader-group-headers=X-Remote-Group
    - --requestheader-username-headers=X-Remote-User
    - --secure-port=6443
    - --service-account-issuer=https://kubernetes.default.svc.cluster.local
    - --service-account-key-file=/etc/kubernetes/pki/sa.pub
    - --service-account-signing-key-file=/etc/kubernetes/pki/sa.key
    - --service-cluster-ip-range=10.96.0.0/12
    - **--tls-cert-file=/etc/kubernetes/pki/apiserver.crt
    - --tls-private-key-file=/etc/kubernetes/pki/apiserver.key**
...</code></pre>
<p>API Server - ETCD 간의 사용되는 인증서는 <code>/etc/kubernetes/pki/apiserver-etcd-client.crt</code>이고, </p>
<p>API Server - Kubelet 간의 사용되는 인증서는 <code>/etc/kubernetes/pki/apiserver-kubelet-client.crt</code>임을 알 수 있다.</p>
<p><code>--tls-cert-file</code> 옵션과 <code>--tls-private-key-file</code> 옵션을 통해 API Server의 인증서와 개인 키를 지정해주는데, <code>/etc/kubernetes/pki/apiserver.crt</code> 인증서를 자세히 살펴보자.</p>
<p>apiserver.crt 파일은 <code>cat</code> 으로 조회 시 기본적으로 암호화되어있는데, <code>openssl</code> 로 인증서의 내용을 조회할 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master pki]$ openssl x509 -in apiserver.crt -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 992286967592286795 (0xdc54fbd5a967a4b)
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=kubernetes
        Validity
            Not Before: Dec 26 05:51:12 2024 GMT
            Not After : Dec 26 05:56:12 2025 GMT
        Subject: CN=kube-apiserver
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:bb:36:ab:9f:4a:df:de:ba:f0:ec:82:19:62:f0:
                    d2:2f:a4:76:1e:e1:26:d4:79:15:2b:1f:0d:32:1e:
                    6f:fe:e5:94:40:d5:9f:78:a2:e6:56:6d:21:f7:56:
                    89:44:8f:e2:35:94:d8:1b:ff:70:cc:91:0c:1c:55:
                    70:53:1a:05:59:a6:b4:50:93:0c:d0:91:07:b1:d2:
                    91:d6:ef:50:d0:cf:32:89:66:df:92:49:9e:7e:3e:
                    78:74:3e:a3:70:23:30:03:c3:6c:b4:41:6b:ca:5f:
                    47:cf:89:70:84:bc:52:40:55:38:53:b2:f8:51:21:
                    c0:3a:86:f9:b4:e8:68:23:e3:af:cb:57:94:94:18:
                    33:2c:80:4c:c7:9e:74:93:99:f3:1c:4a:3c:7d:a5:
                    a9:99:7c:d6:a4:55:62:a8:ec:57:89:f9:57:0c:cd:
                    66:b1:fa:d7:6b:49:1a:8c:1c:34:55:eb:54:82:7c:
                    a8:bd:c7:e2:7b:8b:a6:09:69:78:14:2b:d3:6c:3a:
                    2c:61:ce:31:e4:a6:e5:35:4b:c3:92:01:23:1c:11:
                    81:4d:33:7a:17:87:57:73:78:44:52:f4:29:bf:d3:
                    87:55:c8:7b:41:07:7a:2f:72:9b:7a:fc:a9:94:00:
                    3e:02:5b:3c:29:51:ca:1b:6b:b4:f9:a1:ce:af:0a:
                    a1:6f
                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:
                36:0C:69:00:36:E7:78:B0:45:BD:86:8F:C7:0F:83:96:C5:35:EA:90
            X509v3 Subject Alternative Name:
                DNS:kube-master, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, IP Address:10.96.0.1, IP Address:172.30.1.90
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        03:ce:9d:0f:d4:37:57:a7:08:4f:49:69:48:a6:ae:37:4a:5b:
        6a:4b:64:5a:39:0f:66:b9:8f:0b:5d:dd:bb:aa:51:64:9a:c1:
        71:90:c6:9f:c9:bf:8b:6e:8e:60:8b:f6:44:7c:41:3d:7d:bd:
        2e:af:d2:4e:a1:72:86:67:46:7c:66:e8:e8:3e:1c:ef:71:26:
        f4:89:d4:20:ca:b8:a5:d7:fb:0a:3e:ae:32:24:bf:a5:78:52:
        a2:ce:74:ba:07:ac:dc:c5:59:2d:b0:18:ce:23:0a:9b:06:0b:
        8a:0f:b7:b0:a6:ec:2d:69:24:1c:98:0b:ef:4d:de:ca:27:e5:
        16:a9:5d:65:cc:34:1d:75:d1:5e:67:a2:a1:88:8d:37:f3:e8:
        15:4e:b0:76:69:ff:14:83:15:19:9a:e9:b8:2f:80:ad:16:0d:
        f8:e3:04:2e:74:7c:1c:f3:5b:18:88:f8:91:07:16:b1:8c:9a:
        c5:cd:91:7a:d9:63:4b:23:75:d0:eb:f3:20:9c:40:31:f8:53:
        47:41:0c:b9:f6:73:43:58:a7:36:5c:18:ba:85:c2:f8:e3:fe:
        e7:4e:ab:af:bb:77:da:e2:a3:5e:f9:b3:4d:24:90:ab:eb:f8:
        99:30:a5:0a:8a:59:32:3e:f1:82:73:09:c8:cf:c6:81:ee:e6:
        87:11:5e:cd</code></pre>
<p>위 인증서는 kubernetes CA의 서명을 받아 발급받은 인증서이며, 발급자, 발급날짜, API Server의 공개키와 CA의 서명이 담긴 모습을 확인할 수 있다.</p>
<h3 id="api-server---etcd">API Server - ETCD</h3>
<p>ETCD에서 사용하는 인증서는 <code>/etc/kubernetes/pki/etcd</code> 경로에 위치해있는데, 이는 ETCD 서버의 manifest 파일에 명시되어있다.</p>
<pre><code class="language-yaml">...
spec:
  containers:
  - command:
    - etcd
    - --advertise-client-urls=https://172.30.1.90:2379
    - **--cert-file=/etc/kubernetes/pki/etcd/server.crt**
    - --client-cert-auth=true
    - --data-dir=/var/lib/etcd
    - --experimental-initial-corrupt-check=true
    - --experimental-watch-progress-notify-interval=5s
    - --initial-advertise-peer-urls=https://172.30.1.90:2380
    - --initial-cluster=kube-master=https://172.30.1.90:2380
    - **--key-file=/etc/kubernetes/pki/etcd/server.key**
    - --listen-client-urls=https://127.0.0.1:2379,https://172.30.1.90:2379
    - --listen-metrics-urls=http://127.0.0.1:2381
    - --listen-peer-urls=https://172.30.1.90:2380
    - --name=kube-master
    - --peer-cert-file=/etc/kubernetes/pki/etcd/peer.crt
    - --peer-client-cert-auth=true
    - --peer-key-file=/etc/kubernetes/pki/etcd/peer.key
    - --peer-trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
    - --snapshot-count=10000
    - **--trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt**
...</code></pre>
<p>API Server가 ETCD로 요청을 보낼 때에는 API Server가 클라이언트가 되는데, 이 때 <code>apiserver-etcd-client.crt</code> 인증서로 ETCD에 인증된 사용자임을 증명한다.</p>
<p>그리고 ETCD는 <code>--trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt</code> 옵션으로 명시된 인증서를 통해 해당 클라이언트(API Server)의 인증서가 ETCD가 신뢰할 수 있는 CA 인증서로부터 발급된 인증서인지를 검증한다.</p>
<p><strong><code>/etc/kubernetes/pki/etcd/ca.crt</code></strong> 인증서의 내용을 살펴보면 CA와 인증서 신청자가 etcd-ca로 동일한 것을 볼 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master etcd]# openssl x509 -in ca.crt -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 7686667323274830162 (0x6aac87346e662152)
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=etcd-ca
        Validity
            Not Before: Dec 23 10:21:09 2024 GMT
            Not After : Dec 21 10:26:09 2034 GMT
        Subject: CN=etcd-ca
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:c1:f0:0f:41:d3:16:ef:77:50:c1:22:ba:c6:b3:
                    ac:04:8c:13:dd:81:97:60:93:10:ab:fb:51:0d:87:
                    56:df:3e:0b:b5:0c:ee:fb:bf:cd:f3:85:0a:bc:93:
                    24:7f:b2:bd:8a:b9:ac:ac:42:1e:ce:f0:03:7c:c8:
                    72:aa:81:2b:d4:14:d2:7e:96:7a:b2:40:c2:b3:b3:
                    b7:39:76:b4:0e:71:e9:a0:b7:01:7b:d8:38:e1:9a:
                    fe:76:97:52:62:5d:ac:cb:3f:82:8b:a2:b0:3e:aa:
                    b2:d6:b6:c9:e6:d7:b6:ce:35:81:84:5b:67:a3:e1:
                    32:4c:26:84:1f:c4:93:cc:6c:8f:b2:3a:a4:bc:45:
                    ca:9d:e2:81:52:9c:fe:c0:5d:6e:aa:35:9b:8d:9e:
                    18:39:77:f8:6d:71:30:a8:49:e5:9f:bf:a0:62:58:
                    9f:6f:d8:67:6c:20:52:e2:1e:48:d6:ff:3c:1e:ab:
                    1c:e7:c3:b7:d5:0a:cb:3f:f5:f4:e6:ac:e6:ed:ed:
                    f4:c5:b0:6e:2c:05:a7:00:0c:aa:df:90:1c:ed:5d:
                    6f:1b:87:4c:f9:7f:e3:31:5a:7e:b7:b5:87:40:b9:
                    71:4d:0a:20:fb:06:b2:ee:e5:e4:1a:03:08:7c:7f:
                    a5:ec:97:b9:c2:1a:64:53:4e:9b:7f:5c:af:d6:ae:
                    a2:85
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment, Certificate Sign
            X509v3 Basic Constraints: critical
                CA:TRUE
            X509v3 Subject Key Identifier:
                3F:FE:12:38:2E:34:DC:F6:72:F9:AE:9B:C7:FF:FC:0C:02:06:4B:96
            X509v3 Subject Alternative Name:
                DNS:etcd-ca
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        a5:f2:2f:cf:fd:c2:40:e0:d9:78:3e:d9:e1:26:1a:6d:95:ee:
        7d:44:9a:8a:e3:1f:20:7d:09:3e:1d:0a:c2:df:19:e2:e3:e4:
        52:93:ee:3b:8a:06:1a:ac:ba:41:8d:6a:01:a5:3f:e9:20:75:
        a6:65:04:29:9c:e8:26:af:d3:4d:b2:79:13:55:13:16:8f:12:
        cc:3a:c8:20:50:2d:a8:10:6a:d7:c3:a1:a1:48:26:e9:60:95:
        fb:20:f7:9a:19:2c:bf:b6:0c:fb:9a:79:f1:23:77:25:48:24:
        45:d6:4a:e3:00:7d:7d:d6:e7:4a:97:ef:46:17:0d:e4:a3:28:
        6c:d3:b6:85:e9:f3:5c:a6:6c:1d:41:32:1e:10:31:f9:b4:40:
        7e:ee:c8:ee:8b:cf:6d:6b:31:2a:85:11:69:00:84:24:8d:c1:
        6c:b0:c1:57:42:c3:4a:67:e8:7c:97:0e:48:37:39:ce:3a:f7:
        82:8d:a9:26:66:b5:89:65:cd:d1:af:4e:1e:eb:48:54:b6:ac:
        2d:3e:00:30:f6:3b:b5:0d:9f:0a:99:d5:22:8b:bc:91:88:77:
        c2:ab:cb:72:ff:65:b4:ad:c8:9e:9e:9d:32:2a:3e:78:63:9a:
        30:26:5d:2f:9d:33:b2:6f:c9:15:b6:fe:4c:ac:6b:1e:38:39:
        16:d8:da:2f</code></pre>
<p>이는 CA의 Self Signed 인증서이기 때문인데, 최상위 CA의 경우 자신의 인증서를 검증받을만한 상위의 CA가 없기 때문에 자체 서명된 인증서를 사용한다.</p>
<p>ETCD로 들어오는 요청의 인증서가 위 CA 인증서 담긴 CA의 공개키로 복호화가 된다면, 해당 인증서가 CA로부터 발급받은 인증서가 맞다는 것이 증명되므로, 신뢰할 수 있는 요청임을 알 수 있다.</p>
<p>이제 API Server가 ETCD로 요청을 보낼 때 사용하는 인증서( <strong>/etc/kubernetes/pki/apiserver-etcd-client.crt)</strong>를 살펴보자</p>
<pre><code class="language-yaml">[dgyoon@kube-master pki]# openssl x509 -in apiserver-etcd-client.crt -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 3793171968123556004 (0x34a40dced3f950a4)
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=etcd-ca
        Validity
            Not Before: Dec 26 05:51:12 2024 GMT
            Not After : Dec 26 05:56:12 2025 GMT
        Subject: CN=kube-apiserver-etcd-client
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:a9:79:33:66:55:ce:dc:90:9a:07:96:f4:cd:f6:
                    e6:bc:f8:76:b2:79:bf:ed:10:10:96:66:e9:eb:55:
                    c2:7d:29:32:b6:04:f2:ae:b6:01:f6:77:fb:ee:88:
                    b3:ef:61:5e:67:06:ea:59:20:70:3a:b4:c4:b8:50:
                    24:8b:60:71:4a:1d:8b:c4:0a:ac:f6:f9:90:ed:55:
                    96:07:57:af:6d:59:d0:1f:ef:9d:fd:f8:13:46:01:
                    26:c8:d1:76:f9:fa:94:5f:54:9b:2b:9b:31:97:72:
                    d1:c1:72:8e:bb:b0:3f:6c:91:53:f1:3a:ab:09:a4:
                    d1:71:19:7a:5d:e1:d0:55:a8:65:94:f3:f4:e7:af:
                    07:51:17:22:0e:eb:9b:59:27:bf:99:38:07:d8:59:
                    0b:03:a8:00:aa:d7:3f:83:7b:61:f8:bb:2c:50:c7:
                    43:a5:1d:c0:3a:10:53:92:9d:3c:6f:e2:da:46:3d:
                    8b:e9:42:98:a0:e1:53:80:2a:79:d6:76:c2:0c:00:
                    07:5b:3a:7c:8f:2c:b8:15:70:14:2d:3d:d9:c8:02:
                    69:e1:f6:97:1e:cd:a3:30:fe:de:a9:8f:53:f2:a1:
                    35:a5:1d:34:91:80:9d:4f:49:36:a3:92:05:9d:21:
                    8d:34:a7:52:7f:09:ed:64:57:77:41:81:40:51:6c:
                    0f:25
                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:
                3F:FE:12:38:2E:34:DC:F6:72:F9:AE:9B:C7:FF:FC:0C:02:06:4B:96
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        86:58:f3:22:5b:9d:df:a9:40:24:33:7c:12:3f:99:a1:15:27:
        32:df:b8:a4:34:8d:82:e2:bd:d6:90:4f:33:9d:2a:01:d5:cb:
        a8:c9:93:36:38:9f:c4:ef:ac:ef:17:71:8a:8d:81:85:fd:d6:
        68:67:b0:71:4a:dc:b1:ac:fd:db:38:09:bd:84:15:65:6a:86:
        4b:ec:bb:c2:c0:81:1b:6c:7c:59:c9:d9:78:c5:05:90:6f:da:
        2a:9c:fd:b3:f1:83:b0:b9:8d:1f:26:8b:52:ff:4e:96:e9:ec:
        7f:ff:16:62:ff:69:59:dc:db:2c:f0:bf:9c:54:c9:dc:63:fa:
        5b:84:2d:b5:bb:ff:5f:23:76:ac:ce:48:29:40:3b:f2:df:60:
        c2:07:4d:db:e9:b4:38:11:6e:bd:02:35:c9:53:02:e0:8d:1d:
        cf:b6:a6:d2:4f:da:42:f8:ea:8b:0b:cf:66:04:97:ea:19:2a:
        1e:65:7f:57:39:dc:9d:ac:85:63:18:1c:e6:04:c8:eb:d4:e5:
        5a:c5:78:da:89:47:33:d6:1a:c7:82:d8:dc:0a:8e:53:f0:b4:
        9e:d1:8b:b1:02:dc:e8:93:8c:72:96:8e:89:a7:30:95:d3:2a:
        07:a2:77:7d:51:c5:89:c8:f7:02:10:66:63:a7:d9:63:61:dd:
        d7:48:5a:36</code></pre>
<p>해당 인증서의 발급자가 etcd-ca인 것을 알 수 있고, 최하단에 서명받았다는 것을 알 수 있다.</p>
<p>이제 CA의 공개키로 해당 인증서가 진짜로 etcd-ca로부터 서명을 받았는지 확인해보자.</p>
<pre><code class="language-yaml">[dgyoon@kube-master pki]# openssl verify -CAfile etcd/ca.crt apiserver-etcd-client.crt
apiserver-etcd-client.crt: OK</code></pre>
<p><code>OK</code> 가 출력되었고 이는 ETCD 서버에서 신뢰할 수 있는 인증서라는 의미가 된다.</p>
<h3 id="api-server---controller-manager-scheduler">API Server - Controller Manager, Scheduler</h3>
<p>Static Pod로 실행되는 Controller Manager의 Manifest 파일을 살펴보자.</p>
<pre><code class="language-yaml">[dgyoon@kube-master manifests]# cat kube-controller-manager.yaml
apiVersion: v1
kind: Pod
metadata:
  labels:
    component: kube-controller-manager
    tier: control-plane
  name: kube-controller-manager
  namespace: kube-system
spec:
  containers:
  - command:
    - kube-controller-manager
    - --allocate-node-cidrs=true
    - --authentication-kubeconfig=/etc/kubernetes/controller-manager.conf
    - --authorization-kubeconfig=/etc/kubernetes/controller-manager.conf
    - --bind-address=127.0.0.1
    - --client-ca-file=/etc/kubernetes/pki/ca.crt
    - --cluster-cidr=10.244.0.0/16
    - --cluster-name=kubernetes
    - --cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt
    - --cluster-signing-key-file=/etc/kubernetes/pki/ca.key
    - --controllers=*,bootstrapsigner,tokencleaner
    - --kubeconfig=/etc/kubernetes/controller-manager.conf
    - --leader-elect=true
    - --requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt
    - --root-ca-file=/etc/kubernetes/pki/ca.crt
    - --service-account-private-key-file=/etc/kubernetes/pki/sa.key
    - --service-cluster-ip-range=10.96.0.0/12
    - --use-service-account-credentials=true
    image: registry.k8s.io/kube-controller-manager:v1.31.4
    name: kube-controller-manager
...</code></pre>
<p><code>--client-ca-file=/etc/kubernetes/pki/ca.crt</code> 를 통해 클라이언트 측 인증서를 검증할 수 있는 CA 인증서를 명시해주고 있다.</p>
<blockquote>
<p><code>--cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt</code> , <code>--cluster-signing-key-file=/etc/kubernetes/pki/ca.key</code> 옵션들은 Kubernetes 내에서도 인증서 서명 요청(Certificate Signing Request) 리소스를 생성해 인증서를 발급할 수 있는데, 그 때 CSR Controller가 서명하기 위해 사용하는 인증서와 키라고 한다.</p>
</blockquote>
<p>그리고 Controller Manager가 클라이언트일 때의 요청에 사용되는 인증서는 <code>/etc/kubernetes/controller-manager.conf</code> 파일 내부에 존재한다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master kubernetes]# cat controller-manager.conf
apiVersion: v1
kind: Config
...
users:
- name: system:kube-controller-manager
  user:
    client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0...
    ...
...   </code></pre>
<p><code>users.user.client-certificate-data</code>를 <code>base64</code>로 디코딩해보면 인증서인 것을 확인할 수 있다.</p>
<pre><code class="language-yaml">[root@kube-master kubernetes]# echo &#39;LS0tLS1CRUdJTiBDRVJUSUZJQ0...&#39; | base64 -d
-----BEGIN CERTIFICATE-----
MIIDFjCCAf6gAwIBAgIITi9orqx8XD0wDQYJKoZIhvcNAQELBQAwFTETMBEGA1UE
AxMKa3ViZXJuZXRlczAeFw0yNDEyMjYwNTUxMTJaFw0yNTEyMjYwNTU2MTJaMCkx
...
-----END CERTIFICATE---</code></pre>
<p>그리고 이 인증서를 API Server 측의 CA 인증서로 검증해보면 <code>OK</code> 로 표시되는 것을 확인할 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master kubernetes]# echo &#39;-----BEGIN CERTIFICATE-----
MIIDFjCCAf6gAwIBAgIITi9orqx8XD0wDQYJKoZIhvcNAQELBQAwFTETMBEGA1UE
AxMKa3ViZXJuZXRlczAeFw0yNDEyMjYwNTUxMTJaFw0yNTEyMjYwNTU2MTJaMCkx
...
-----END CERTIFICATE-----&#39; &gt; controller.crt
[dgyoon@kube-master kubernetes]# openssl verify -CAfile pki/ca.crt controller.crt
controller.crt: OK</code></pre>
<p>이와 동일하게 Scheduler의 인증서도 <code>/etc/kubernetes/scheduler.conf</code>에 존재하고 동일한 과정을 통해 확인해볼 수 있다.</p>
<p>API Server가 Controller Manager와 Scheduler에 요청을 보내는 클라이언트 입장일 때 사용되는 인증서가 따로 지정되지 않는 이유는 API Server는 두 컴포넌트에게 요청을 받기만 할 뿐 보내지는 않기 때문이다.</p>
<h3 id="api-server---kubelet">API Server - Kubelet</h3>
<p><code>systemctl</code>로 Kubelet의 실행 상태를 확인해볼 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master kubernetes]# systemctl status kubelet
● kubelet.service - kubelet: The Kubernetes Node Agent
     Loaded: loaded (/usr/lib/systemd/system/kubelet.service; enabled; preset: disabled)
    Drop-In: /usr/lib/systemd/system/kubelet.service.d
             └─10-kubeadm.conf
     Active: active (running) since Thu 2024-12-26 14:58:21 KST; 3 months 3 days ago
       Docs: https://kubernetes.io/docs/
   Main PID: 2559479 (kubelet)
      Tasks: 31 (limit: 408096)
     Memory: 138.4M
        CPU: 1d 2h 163ms
     CGroup: /system.slice/kubelet.service
             └─2559479 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf --config=/var/lib/kubelet/config.yaml --container-runtime-endpoint=unix:///var/run/containerd/containerd.sock --pod-infra-container-ima&gt;</code></pre>
<p><code>--kubeconfig=/etc/kubernetes/kubelet.conf</code> , <code>--config=/var/lib/kubelet/config.yaml</code> 옵션을 통해 Kubelet 설정 파일의 위치를 확인할 수 있다.</p>
<pre><code class="language-yaml">[root@kube-master kubernetes]# cat /etc/kubernetes/kubelet.conf
apiVersion: v1
kind: Config
users:
- name: system:node:kube-master
  user:
    client-certificate: /var/lib/kubelet/pki/kubelet-client-current.pem
    client-key: /var/lib/kubelet/pki/kubelet-client-current.pem
...    </code></pre>
<p>확인해보니 Kubelet의 인증서와 키는 <code>/var/lib/kubelet/pki/kubelet-client-current.pem</code> 경로에 있음을 알 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master kubernetes]# cat /var/lib/kubelet/pki/kubelet-client-current.pem
-----BEGIN CERTIFICATE-----
MIIDJjCCAg6gAwIBAgIIG5KYerIJhJEwDQYJKoZIhvcNAQELBQAwFTETMBEGA1UE
AxMKa3ViZXJuZXRlczAeFw0yNDEyMjMxMDIxMDlaFw0yNTEyMjMxMDI2MTBaMDkx
...
-----END CERTIFICATE-----
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAtPgxFtXGqkdo8tBkDyBLTV75UGHCwOfE9K294rS+2eYsqI3V
KPq2MEVA0F+mirhjGl57F/LueNwW1XyiUVhqNbQZ1Ab/EOsO1RS2Exi4op/nnUEm
...
-----END RSA PRIVATE KEY-----</code></pre>
<p>위 인증서는 Kubelet이 API Server에 클라이언트로서 요청을 보낼 때 사용하는 인증서이며, pem 파일에서 인증서만 추출해 API Server의 CA 인증서로 검증해보면 <code>OK</code> 가 나오는 것을 확인해볼 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master kubernetes]# echo &#39;-----BEGIN CERTIFICATE-----
MIIDJjCCAg6gAwIBAgIIG5KYerIJhJEwDQYJKoZIhvcNAQELBQAwFTETMBEGA1UE
AxMKa3ViZXJuZXRlczAeFw0yNDEyMjMxMDIxMDlaFw0yNTEyMjMxMDI2MTBaMDkx
...
-----END CERTIFICATE-----&#39; &gt; kubelet.crt
[dgyoon@kube-master kubernetes]# openssl verify -CAfile pki/ca.crt kubelet.crt
kubelet.crt: OK</code></pre>
<p>반대로 API Server가 클라이언트일 때는 <code>/etc/kubernetes/pki/apiserver-kubelet-client.crt</code> 에 위치한 인증서가 사용되며(API Server Manifest 파일에서 확인), Kubelet이 클라이언트 요청에 검증하는 인증서는 <code>/var/lib/kubelet/config.yaml</code> 에서 확인 결과 <code>/etc/kubernetes/pki/ca.crt</code> 파일이다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master pki]# cat /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
authentication:
  anonymous:
    enabled: false
  webhook:
    cacheTTL: 0s
    enabled: true
  x509:
    clientCAFile: /etc/kubernetes/pki/ca.crt
...    </code></pre>
<p>그리고 <code>openssl</code>로 확인해보면 유효한 인증서임을 확인할 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master pki]# openssl verify -CAfile /etc/kubernetes/pki/ca.crt /etc/kubernetes/pki/apiserver-kubelet-client.crt
/etc/kubernetes/pki/apiserver-kubelet-client.crt: OK</code></pre>
<h3 id="api-server---kubectl">API Server - Kubectl</h3>
<p>관리자가 사용하는 <code>kubectl</code>명령어는 API Server로 쉽게 요청을 보내주는 클라이언트 측 명령어에 불과하다.</p>
<p>즉, <code>kubectl</code> 명령어를 사용하는 클라이언트 역시 자신이 신뢰할 수 있는 사용자라는 것을 인증서를 통해 증명해야한다.</p>
<p>주로 사용자의 홈 디렉토리의 <code>.kube/config</code> 에 위치한다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master .kube]# cat config
apiVersion: v1
kind: Config
preferences: {}
users:
- name: kubernetes-admin
  user:
    client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURJVENDQWdtZ0F3SUJBZ0lJTjZRTHJHY1VsSjh3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TkRFeU1qTXhNREl4TURsYUZ3MHlOVEV5TWpNeE1ESTJNRGxhTURReApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sa3dGd1lEVlFRREV4QnJkV0psY201bGRHVnpMV0ZrCmJXbHVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXQ5c0dBOWczanpZYlYzYmQKY0MyTVNON3NicUZJR0U5SEx2bjllT1Uyd0JDT3UxeTFDdTkwNUc2SmpTbnV5N0d6bGlnQTMrcndZVnI1dzZOcgowc0NpalVFOVV4NkJkNExWbVJkb3hUUng4Mlg1NnVLQllzdXR0OUpFZTk0NkswYm9yVmdKODZKVWhZNllnU2NWCmxaakpCb0pWU2hJaVUyYkxqaGhvMERUM2hwb3llT3ViaFBqdFphREpFZk1FWGNoOGhWM1VDVzU0ME10Wk9JUVQKd0pUQ2RzZ1VmSUhJNTQ1dllmcGFnUlRxeFdUdVBsRnI3bmVXS1IvSFJGNDVJUW9KS0l2cmNMSTJ1elRWM240MQp4L2tCbXE3bWJEeVR5QjkvMUgyQy9qdk9pL1pnTVlZQ0tUdFZWWTM4UnlkMmRISWhYNUlmNnJhbjg4dG1jMHBqCi9NT3RTd0lEQVFBQm8xWXdWREFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUgKQXdJd0RBWURWUjBUQVFIL0JBSXdBREFmQmdOVkhTTUVHREFXZ0JRMkRHa0FOdWQ0c0VXOWhvL0hENE9XeFRYcQprREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBWDRBVEpFSy9XNUo4YWcxdUhPdUg4K0psT2JCUDVPdWcwSGN6CmNNNFNkbXY3a0ZtUTFmVWwvWnhrMm5aZ1Nva2hJOVNJbHNmSmc2bHBmS0RDaHlhZXZGRm15MThpV29hYWpGdXkKeTNCV09FN2pGMmVnY3NhMnFXU1lSNUNVWEsyMEJVL2VFRUoyNHpBa2h5ZWtCVjI2M3RtR2xmNExwR0c0Y0RncgpSSHoxRHhLSytIQjB5NWpuZ2pFdkhvRkdXRFVBcENjTFRKWjFlenowbjBCVlphbXVrWTZWNjlIZjF3R0ZzVHlyCklKVGRYY1lSYTRFNkVHczRSSEllNHRYcGh2RW1VaFdRUFdIcHphNjlDOE8vVTQrTXMrdUQ3MmdVdFB0Ym15VlgKS2h6SW5YNS84bjR0ejh0azhzRVFsYVl1eTVvSUorMUFwVElLUFFtRDhYWGpWNmZhdlE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==  
...    </code></pre>
<p><code>client-certificate-data</code> 데이터를 디코딩하고 이를 API Server의 CA 인증서로 검증해보면 <code>OK</code>가 나오는 것을 확인할 수 있다.</p>
<pre><code class="language-yaml">[dgyoon@kube-master .kube]# echo &#39;LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0t...&#39; | base64 -d &gt; kubectl.crt
[dgyoon@kube-master .kube]# openssl verify -CAfile /etc/kubernetes/pki/ca.crt kubectl.crt
kubectl.crt: OK</code></pre>
<h3 id="마치며">마치며</h3>
<p>이번 글에서는 Kubernetes Control Plane 컴포넌트 간의 통신이 mTLS로 상호 인증을 통해 보호되며, X.509 인증서를 사용한다는 것을 정리했다.</p>
<p>그리고 그 과정에 사용되는 인증서들을 하나씩 검증해보았고 모두 검증이 되는 것을 확인했다.</p>
<p>인증서 관련 트러블슈팅을 위한 기초 지식을 다질 수 있었고, 매번 잊어버렸던 X.509 인증서와 mTLS 또한 상기해볼 수 있는 시간이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Atlantis로 Terraform 협업하기]]></title>
            <link>https://velog.io/@dong-gwan/Atlantis%EB%A1%9C-Terraform-%ED%98%91%EC%97%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dong-gwan/Atlantis%EB%A1%9C-Terraform-%ED%98%91%EC%97%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 26 Dec 2024 09:38:00 GMT</pubDate>
            <description><![CDATA[<h2 id="atlantis">Atlantis</h2>
<p>Atlantis(아틀란티스)는 Terraform 사용 시 Git Pull Request을 통해 협업할 수 있는 환경을 만들어주는 CNCF 프로젝트이다.</p>
<h2 id="공식-문서">공식 문서</h2>
<ul>
<li><a href="https://www.runatlantis.io/">https://www.runatlantis.io/</a></li>
</ul>
<h2 id="0-필수-요구-사항">0. 필수 요구 사항</h2>
<ul>
<li>Atlantis 서버를 호스팅할 머신</li>
<li>온라인 Git 저장소(Github, Gitlab 등..)</li>
<li>state를 저장할 backend 설정</li>
<li>Comment를 생성할 Git 계정의 엑세스 토큰</li>
<li>Webhook Secret</li>
</ul>
<h2 id="1-설치">1. 설치</h2>
<ul>
<li><a href="https://www.runatlantis.io/docs/deployment.html">https://www.runatlantis.io/docs/deployment.html</a></li>
</ul>
<p>Helm, Manifest, Docker 설치 방법은 여러 가지있고 여기서는 Docker Compose로 실행했다.</p>
<p><strong>docker-compose.yaml</strong></p>
<pre><code>services:
  atlantis:
    image: ghcr.io/runatlantis/atlantis
    command: server --gh-user=YoonDongGwan --gh-token=[PERSONAL_ACCESS_TOKEN] --repo-allowlist=github.com/YoonDongGwan/* --gh-webhook-secret=[WEBHOOK_SECRET]
    ports:
    - 4141:4141
    volumes:
    - ~/.aws:/home/atlantis/.aws</code></pre><pre><code>$ docker compose up -d</code></pre><p><code>http://호스팅한 머신의 IP:4141</code>로 접속해서 아래와 같은 웹이 보이면 성공이다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/59b54d5f-48f3-4378-b10d-7416bfe97d00/image.png" alt=""></p>
<h2 id="2-github-webhook">2. Github Webhook</h2>
<p>사용 중인 Git 저장소가 Github이므로 Github Webhook을 설정해준다.</p>
<p>Terraform Repo → <code>Settings</code> → <code>Webhooks</code> → <code>Add webhook</code>
<img src="https://velog.velcdn.com/images/dong-gwan/post/16f5c8c0-41be-4687-8af7-4e5ef1a1b647/image.png" alt=""></p>
<p>Payload URL에는 외부에서 접근 가능한 아틀란티스 URL을, Secret에는 자체 생산한 24자리 이상의 Webhook Secret을 넣어준다.</p>
<p>Webhook Secret은 24자리 이상의 아무 알파벳과 숫자의 조합으로 직접 만들면 된다.</p>
<p><code>Which events would you like to trigger this webhook?</code> 항목에서는 <code>Let me select individual events.</code> 를 선택하고 아래의 항목을 선택해준다.</p>
<ul>
<li>Issue comments</li>
<li>Pull request reviews</li>
<li>Pull requests</li>
<li>Pushes</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/f1280f82-13fa-4ab4-a897-633b079c8a33/image.png" alt=""></p>
<h2 id="3-pull-request">3. Pull Request</h2>
<p>브랜치명은 상관없지만 여기서는 새로운 브랜치 <code>atlantis</code>를 만들고 Terraform 코드에 원하는대로 수정을 줘 Git 저장소에 Push한다.</p>
<p>그리고 해당 <code>main ← atlantis</code> Pull Request를 생성하면 Atlantis에서 Webhook을 통해 감지하여 자동으로 코멘트를 생성해준다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/8fafb5a8-7845-45f4-a14d-b3ba85b2ee56/image.png" alt=""></p>
<p>그리고 <code>Show Output</code>을 열어보면 <code>terraform plan</code> 한 것과 같이 인프라 변경 사항이 출력된다.</p>
<pre><code>Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.ec2_atlantis.aws_instance.ec2_instance must be replaced
-/+ resource &quot;aws_instance&quot; &quot;ec2_instance&quot; {
      ~ arn                                  = &quot;arn:aws:ec2:ap-northeast-2:xxxxxxxxxx:instance/i-032238b4c2f67a644&quot; -&gt; (known after apply)
      ~ associate_public_ip_address          = true -&gt; (known after apply)
      ~ availability_zone                    = &quot;ap-northeast-2a&quot; -&gt; (known after apply)
      ~ cpu_core_count                       = 1 -&gt; (known after apply)
      ~ cpu_threads_per_core                 = 2 -&gt; (known after apply)
      ~ disable_api_stop                     = false -&gt; (known after apply)
      ~ disable_api_termination              = false -&gt; (known after apply)
      ~ ebs_optimized                        = false -&gt; (known after apply)
      - hibernation                          = false -&gt; null
      + host_id                              = (known after apply)
      + host_resource_group_arn              = (known after apply)
      + iam_instance_profile                 = (known after apply)
      ~ id                                   = &quot;i-032238b4c2f67a644&quot; -&gt; (known after apply)
      ~ instance_initiated_shutdown_behavior = &quot;stop&quot; -&gt; (known after apply)
      + instance_lifecycle                   = (known after apply)
      ~ instance_state                       = &quot;running&quot; -&gt; (known after apply)
      ~ ipv6_address_count                   = 0 -&gt; (known after apply)
      ~ ipv6_addresses                       = [] -&gt; (known after apply)
      ~ key_name                             = &quot;terraform-20241219080943026000000001&quot; -&gt; (known after apply) # forces replacement
      ~ monitoring                           = false -&gt; (known after apply)
      + outpost_arn                          = (known after apply)
      + password_data                        = (known after apply)
      + placement_group                      = (known after apply)
      ~ placement_partition_number           = 0 -&gt; (known after apply)
      ~ primary_network_interface_id         = &quot;eni-0c0f89f50f4140dc0&quot; -&gt; (known after apply)
      ~ private_dns                          = &quot;ip-10-100-0-215.ap-northeast-2.compute.internal&quot; -&gt; (known after apply)
      ~ private_ip                           = &quot;10.100.0.215&quot; -&gt; (known after apply)
      ~ public_dns                           = &quot;ec2-3-35-231-83.ap-northeast-2.compute.amazonaws.com&quot; -&gt; (known after apply)
      ~ public_ip                            = &quot;3.35.231.83&quot; -&gt; (known after apply)
      ~ secondary_private_ips                = [] -&gt; (known after apply)
      ~ security_groups                      = [] -&gt; (known after apply)
      + spot_instance_request_id             = (known after apply)
        tags                                 = {
            &quot;Name&quot; = &quot;ec2-ap-northeast-2a-atlantis&quot;
        }
      ~ tenancy                              = &quot;default&quot; -&gt; (known after apply)
      + user_data                            = (known after apply)
      + user_data_base64                     = (known after apply)
        # (8 unchanged attributes hidden)

      ~ capacity_reservation_specification (known after apply)
      - capacity_reservation_specification {
          - capacity_reservation_preference = &quot;open&quot; -&gt; null
        }

      ~ cpu_options (known after apply)
      - cpu_options {
          - core_count       = 1 -&gt; null
          - threads_per_core = 2 -&gt; null
            # (1 unchanged attribute hidden)
        }

      - credit_specification {
          - cpu_credits = &quot;unlimited&quot; -&gt; null
        }

      ~ ebs_block_device (known after apply)

      ~ enclave_options (known after apply)
      - enclave_options {
          - enabled = false -&gt; null
        }

      ~ ephemeral_block_device (known after apply)

      ~ instance_market_options (known after apply)

      ~ maintenance_options (known after apply)
      - maintenance_options {
          - auto_recovery = &quot;default&quot; -&gt; null
        }

      ~ metadata_options (known after apply)
      - metadata_options {
          - http_endpoint               = &quot;enabled&quot; -&gt; null
          - http_protocol_ipv6          = &quot;disabled&quot; -&gt; null
          - http_put_response_hop_limit = 2 -&gt; null
          - http_tokens                 = &quot;required&quot; -&gt; null
          - instance_metadata_tags      = &quot;disabled&quot; -&gt; null
        }

      ~ network_interface (known after apply)

      ~ private_dns_name_options (known after apply)
      - private_dns_name_options {
          - enable_resource_name_dns_a_record    = false -&gt; null
          - enable_resource_name_dns_aaaa_record = false -&gt; null
          - hostname_type                        = &quot;ip-name&quot; -&gt; null
        }

      ~ root_block_device (known after apply)
      - root_block_device {
          - delete_on_termination = true -&gt; null
          - device_name           = &quot;/dev/xvda&quot; -&gt; null
          - encrypted             = false -&gt; null
          - iops                  = 3000 -&gt; null
          - tags                  = {} -&gt; null
          - tags_all              = {} -&gt; null
          - throughput            = 125 -&gt; null
          - volume_id             = &quot;vol-0b1677c1b98156cbc&quot; -&gt; null
          - volume_size           = 30 -&gt; null
          - volume_type           = &quot;gp3&quot; -&gt; null
            # (1 unchanged attribute hidden)
        }
    }

... 이하 생략

Plan: 8 to add, 0 to change, 2 to destroy.</code></pre><p>그리고 다시 Atlantis 웹으로 가보면 어떤 Job이 계획 중인지 확인할 수 있고 동시성 이슈를 고려해 Lock이 자동으로 걸려있는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/d52879c2-4917-4be1-afed-90cd80937c73/image.png" alt=""></p>
<h2 id="4-atlantis-apply">4. atlantis apply</h2>
<p><code>Show Output</code> 으로 인프라 변경 사항을 파악했다면 이슈 코멘트에 <code>atlantis [COMMAND]</code> 를 추가해 계획을 실행, 취소할 수 있다.</p>
<p><code>atlantis apply</code> 는 변경 계획을 실제로 실행하며, <code>atlantis unlock</code> 은 계획을 취소한다.</p>
<p><code>atlantis plan</code> 은 변경 계획을 다시 출력해준다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/234d8bb6-0e77-43db-a257-344b1bf33bc0/image.png" alt=""></p>
<h2 id="-bonus-infracost"># Bonus. Infracost</h2>
<p><code>Infracost</code>라는 솔루션을 사용하면 인프라 변경 사항을 적용하였을 때, 예상 증가/감소 비용을 계산할 수 있다.</p>
<ul>
<li><a href="https://www.infracost.io/">https://www.infracost.io/</a></li>
</ul>
<p><code>Infracost</code> 측에서 <code>Atlantis</code>와 같이 사용할 수 있는 이미지를 제공해주기 때문에 이를 사용해보려한다.</p>
<p>먼저 Infracost 홈페이지에서 회원가입한 이후 <code>Settings → Org settings → API tokens</code> 로 이동해 <code>CLI and CI/CD token</code> 을 복사해준다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/09056402-d2d1-45d2-9650-a1aa6a91e2f8/image.png" alt=""></p>
<p>그리고 도커 컴포즈 파일을 생성해주고</p>
<p><strong>docker-compose.yaml</strong></p>
<pre><code>services:
  atlantis:
    image: infracost/infracost-atlantis:latest
    command: server --gh-user=YoonDongGwan --gh-token=[PERSONAL_ACCESS_TOKEN] --repo-allowlist=github.com/YoonDongGwan/* --gh-webhook-secret=[WEBHOOK_SECRET] --repo-config=/tmp/repo-config.json
    ports:
    - 4141:4141
    volumes:
    - ~/.aws:/home/atlantis/.aws
    - ./repo-config.json:/tmp/repo-config.json</code></pre><p>컨피그 파일을 생성해주는데 위에서 복사한 토큰을 환경변수 <code>INFRACOST_API_KEY</code> 에 주입시킨다.</p>
<p><strong>repo-config.json</strong></p>
<pre><code>{
      &quot;repos&quot;: [
        {
          &quot;id&quot;: &quot;/.*/&quot;,
          &quot;workflow&quot;: &quot;terraform-infracost&quot;
        }
      ],
      &quot;workflows&quot;: {
        &quot;terraform-infracost&quot;: {
          &quot;plan&quot;: {
            &quot;steps&quot;: [
              &quot;init&quot;,
              &quot;plan&quot;,
              {
                &quot;env&quot;: {
                  &quot;name&quot;: &quot;INFRACOST_API_KEY&quot;,
                  &quot;value&quot;: &quot;[YOUR_INFRACOST_TOKEN]&quot;
                }
              },
              {
                &quot;env&quot;: {
                  &quot;name&quot;: &quot;INFRACOST_TERRAFORM_BINARY&quot;,
                  &quot;command&quot;: &quot;echo \&quot;terraform${ATLANTIS_TERRAFORM_VERSION}\&quot;&quot;
                }
              },
              {
                &quot;run&quot;: &quot;/home/atlantis/infracost_atlantis_diff.sh&quot;
              }
            ]
          }
        }
      }
    }</code></pre><pre><code>$ docker compose up -d</code></pre><p>그리고 <code>Atlantis</code>를 사용한 것과 똑같이 Pull Request를 생성해보면, 똑같은 이슈 코멘트가 생성되고 <code>Show Output</code>을 열어 밑으로 내려보면 <code>Infracost</code>가 계산해준 예상 비용을 확인해볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/ff46de68-00f8-40e1-8951-7b4a3808bb53/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[kubeadm으로 쿠버네티스 클러스터 버전 업그레이드(v1.27 → v1.31)]]></title>
            <link>https://velog.io/@dong-gwan/kubeadm%EC%9C%BC%EB%A1%9C-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EB%B2%84%EC%A0%84-%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9Cv1.27-v1.31</link>
            <guid>https://velog.io/@dong-gwan/kubeadm%EC%9C%BC%EB%A1%9C-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EB%B2%84%EC%A0%84-%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9Cv1.27-v1.31</guid>
            <pubDate>Thu, 26 Dec 2024 06:39:20 GMT</pubDate>
            <description><![CDATA[<h3 id="주의-사항">주의 사항</h3>
<ul>
<li>Rocky Linux 9버전으로 진행하였으므로, 공식 문서에서 <code>CentOS, RHEL or Fedora</code> 탭에 적힌 명령어를 사용하였다.</li>
<li>컨트롤 플레인은 메이저 버전을 건너뛰어 업그레이드할 수 없다. 즉, 1.27 → 1.28 → 1.29 → ... 이런 식으로 단계적으로 업그레이드 해야 한다.</li>
<li>워커 노드의 업그레이드 시 <code>kubectl drain</code>으로 파드를 노드에서 제거하고 다른 노드에 옮긴 후 진행해야 한다.</li>
</ul>
<h3 id="공식-문서">공식 문서</h3>
<ul>
<li><a href="https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/">https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/</a></li>
</ul>
<h3 id="0-etcd-백업">0. etcd 백업</h3>
<p>쿠버네티스는 모든 작업을 API 서버를 통해 수행하고, 수행된 결과는 etcd 서버에 저장된다.</p>
<p>즉, etcd에는 쿠버네티스 클러스터의 모든 상태가 저장되어 있기 때문에 만약 컨트롤 플레인이 모두 망가져버리면 etcd 백업본을 통해 복구할 수 있다.</p>
<pre><code>etcdctl snapshot save [SNAPSHOT_NAME] \
--ca-file=/etc/kubernetes/ca.crt \
--cert-file=/etc/kubernetes/server.crt \
--key-file=/etc/kubernetes/server.key</code></pre><blockquote>
<p>클라이언트 측에서 <strong>etcd</strong>의 CA 인증서와 클라이언트 키를 제시해야 하는 이유?
→ 서버 측에서는 클라이언트 측의 인증서를 받고 소유하고 있는 CA 인증서를 통해 해당 클라이언트가 인증된 사용자임을 알 수 있지만, 클라이언트 측에서는 해당 etcd 서버가 인증된 서버인지 알 수 없기 때문에, 상호 TLS 인증의 목적으로 제시하는 것이며, 실제 etcd 서버에 전달되는 파일은 오직 <code>--cert-file</code>에 명시한 클라이언트 측 인증서 뿐이고 <code>--ca-file</code>에 명시된 파일과 <code>--key-file</code>에 명시된 파일은 etcd로부터 수신한 인증서를 해독하기 위해 쓰인다.</p>
</blockquote>
<h3 id="1-kubeadm-repo-업데이트">1. kubeadm Repo 업데이트</h3>
<pre><code>cat &lt;&lt;EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v[VERSION]/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v[VERSION]/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF</code></pre><p><code>[VERSION]</code>에 업데이트하고자 하는 버전을 기입한다.</p>
<p>그리고 <code>yum list --showduplicates kubeadm --disableexcludes=kubernetes</code> 명령어로 원하는 버전이 맞는지와 마이너 버전까지 확인한다.</p>
<pre><code>$ yum list --showduplicates kubeadm --disableexcludes=kubernetes
마지막 메타자료 만료확인(0:05:44 이전): 2024년 12월 23일 (월) 오후 01시 08분 36초.
설치된 꾸러미
kubeadm.x86_64                                                                                                                       1.28.15-150500.1.1                                                                                                                      @kubernetes
사용 가능한 꾸러미
kubeadm.aarch64                                                                                                                      1.28.0-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.0-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.0-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.0-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.0-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.1-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.1-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.1-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.1-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.1-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.2-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.2-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.2-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.2-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.2-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.3-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.3-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.3-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.3-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.3-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.4-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.4-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.4-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.4-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.4-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.5-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.5-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.5-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.5-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.5-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.6-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.6-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.6-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.6-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.6-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.7-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.7-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.7-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.7-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.7-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.8-150500.1.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.8-150500.1.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.8-150500.1.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.8-150500.1.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.8-150500.1.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.9-150500.2.1                                                                                                                       kubernetes
kubeadm.ppc64le                                                                                                                      1.28.9-150500.2.1                                                                                                                       kubernetes
kubeadm.s390x                                                                                                                        1.28.9-150500.2.1                                                                                                                       kubernetes
kubeadm.src                                                                                                                          1.28.9-150500.2.1                                                                                                                       kubernetes
kubeadm.x86_64                                                                                                                       1.28.9-150500.2.1                                                                                                                       kubernetes
kubeadm.aarch64                                                                                                                      1.28.10-150500.1.1                                                                                                                      kubernetes
kubeadm.ppc64le                                                                                                                      1.28.10-150500.1.1                                                                                                                      kubernetes
kubeadm.s390x                                                                                                                        1.28.10-150500.1.1                                                                                                                      kubernetes
kubeadm.src                                                                                                                          1.28.10-150500.1.1                                                                                                                      kubernetes
kubeadm.x86_64                                                                                                                       1.28.10-150500.1.1                                                                                                                      kubernetes
kubeadm.aarch64                                                                                                                      1.28.11-150500.1.1                                                                                                                      kubernetes
kubeadm.ppc64le                                                                                                                      1.28.11-150500.1.1                                                                                                                      kubernetes
kubeadm.s390x                                                                                                                        1.28.11-150500.1.1                                                                                                                      kubernetes
kubeadm.src                                                                                                                          1.28.11-150500.1.1                                                                                                                      kubernetes
kubeadm.x86_64                                                                                                                       1.28.11-150500.1.1                                                                                                                      kubernetes
kubeadm.aarch64                                                                                                                      1.28.12-150500.1.1                                                                                                                      kubernetes
kubeadm.ppc64le                                                                                                                      1.28.12-150500.1.1                                                                                                                      kubernetes
kubeadm.s390x                                                                                                                        1.28.12-150500.1.1                                                                                                                      kubernetes
kubeadm.src                                                                                                                          1.28.12-150500.1.1                                                                                                                      kubernetes
kubeadm.x86_64                                                                                                                       1.28.12-150500.1.1                                                                                                                      kubernetes
kubeadm.aarch64                                                                                                                      1.28.13-150500.1.1                                                                                                                      kubernetes
kubeadm.ppc64le                                                                                                                      1.28.13-150500.1.1                                                                                                                      kubernetes
kubeadm.s390x                                                                                                                        1.28.13-150500.1.1                                                                                                                      kubernetes
kubeadm.src                                                                                                                          1.28.13-150500.1.1                                                                                                                      kubernetes
kubeadm.x86_64                                                                                                                       1.28.13-150500.1.1                                                                                                                      kubernetes
kubeadm.aarch64                                                                                                                      1.28.14-150500.2.1                                                                                                                      kubernetes
kubeadm.ppc64le                                                                                                                      1.28.14-150500.2.1                                                                                                                      kubernetes
kubeadm.s390x                                                                                                                        1.28.14-150500.2.1                                                                                                                      kubernetes
kubeadm.src                                                                                                                          1.28.14-150500.2.1                                                                                                                      kubernetes
kubeadm.x86_64                                                                                                                       1.28.14-150500.2.1                                                                                                                      kubernetes
kubeadm.aarch64                                                                                                                      1.28.15-150500.1.1                                                                                                                      kubernetes
kubeadm.ppc64le                                                                                                                      1.28.15-150500.1.1                                                                                                                      kubernetes
kubeadm.s390x                                                                                                                        1.28.15-150500.1.1                                                                                                                      kubernetes
kubeadm.src                                                                                                                          1.28.15-150500.1.1                                                                                                                      kubernetes
kubeadm.x86_64                                                                                                                       1.28.15-150500.1.1                                                                                                                      kubernetes</code></pre><h3 id="2-install-kubeadm">2. Install kubeadm</h3>
<pre><code>yum install -y kubeadm-1.28.15 --disableexcludes=kubernetes</code></pre><h3 id="3-kubeadm-upgrade">3. Kubeadm Upgrade</h3>
<p>먼저 <code>kubeadm upgrade plan</code> 으로 업그레이드 계획을 확인한 뒤,</p>
<pre><code>$ kubeadm upgrade plan
[upgrade/config] Making sure the configuration is correct:
[upgrade/config] Reading configuration from the cluster...
[upgrade/config] FYI: You can look at this config file with &#39;kubectl -n kube-system get cm kubeadm-config -o yaml&#39;
[preflight] Running pre-flight checks.
[upgrade] Running cluster health checks
[upgrade] Fetching available versions to upgrade to
[upgrade/versions] Cluster version: v1.27.3
[upgrade/versions] kubeadm version: v1.28.15
I1223 13:15:22.418561 4164243 version.go:256] remote version is much newer: v1.32.0; falling back to: stable-1.28
[upgrade/versions] Target version: v1.28.15
[upgrade/versions] Latest version in the v1.27 series: v1.27.16

Components that must be upgraded manually after you have upgraded the control plane with &#39;kubeadm upgrade apply&#39;:
COMPONENT   CURRENT       TARGET
kubelet     2 x v1.27.3   v1.27.16

Upgrade to the latest version in the v1.27 series:

COMPONENT                 CURRENT   TARGET
kube-apiserver            v1.27.3   v1.27.16
kube-controller-manager   v1.27.3   v1.27.16
kube-scheduler            v1.27.3   v1.27.16
kube-proxy                v1.27.3   v1.27.16
CoreDNS                   v1.10.1   v1.10.1
etcd                      3.5.7-0   3.5.15-0

You can now apply the upgrade by executing the following command:

        kubeadm upgrade apply v1.27.16

_____________________________________________________________________

Components that must be upgraded manually after you have upgraded the control plane with &#39;kubeadm upgrade apply&#39;:
COMPONENT   CURRENT       TARGET
kubelet     2 x v1.27.3   v1.28.15

Upgrade to the latest stable version:

COMPONENT                 CURRENT   TARGET
kube-apiserver            v1.27.3   v1.28.15
kube-controller-manager   v1.27.3   v1.28.15
kube-scheduler            v1.27.3   v1.28.15
kube-proxy                v1.27.3   v1.28.15
CoreDNS                   v1.10.1   v1.10.1
etcd                      3.5.7-0   3.5.15-0

You can now apply the upgrade by executing the following command:

        kubeadm upgrade apply v1.28.15

_____________________________________________________________________


The table below shows the current state of component configs as understood by this version of kubeadm.
Configs that have a &quot;yes&quot; mark in the &quot;MANUAL UPGRADE REQUIRED&quot; column require manual config upgrade or
resetting to kubeadm defaults before a successful upgrade can be performed. The version to manually
upgrade to is denoted in the &quot;PREFERRED VERSION&quot; column.

API GROUP                 CURRENT VERSION   PREFERRED VERSION   MANUAL UPGRADE REQUIRED
kubeproxy.config.k8s.io   v1alpha1          v1alpha1            no
kubelet.config.k8s.io     v1beta1           v1beta1             no
_____________________________________________________________________</code></pre><p>문제없다면 <code>kubeadm upgrade apply v1.28.15</code></p>
<pre><code>$ kubeadm upgrade apply v1.28.15
[upgrade/config] Making sure the configuration is correct:
[upgrade/config] Reading configuration from the cluster...
[upgrade/config] FYI: You can look at this config file with &#39;kubectl -n kube-system get cm kubeadm-config -o yaml&#39;
[preflight] Running pre-flight checks.
[upgrade] Running cluster health checks
[upgrade/version] You have chosen to change the cluster version to &quot;v1.28.15&quot;
[upgrade/versions] Cluster version: v1.27.3
[upgrade/versions] kubeadm version: v1.28.15
[upgrade] Are you sure you want to proceed? [y/N]: y
[upgrade/prepull] Pulling images required for setting up a Kubernetes cluster
[upgrade/prepull] This might take a minute or two, depending on the speed of your internet connection
[upgrade/prepull] You can also perform this action in beforehand using &#39;kubeadm config images pull&#39;
W1223 13:16:22.963095 4164968 checks.go:835] detected that the sandbox image &quot;time=\&quot;2024-12-23T13:16:22+09:00\&quot; level=warning msg=\&quot;Config \\\&quot;/etc/crictl.yaml\\\&quot; does not exist, trying next: \\\&quot;/usr/bin/crictl.yaml\\\&quot;\&quot;\nregistry.k8s.io/pause:3.6&quot; of the container runtime is inconsistent with that used by kubeadm. It is recommended that using &quot;registry.k8s.io/pause:3.9&quot; as the CRI sandbox image.
[upgrade/apply] Upgrading your Static Pod-hosted control plane to version &quot;v1.28.15&quot; (timeout: 5m0s)...
[upgrade/etcd] Upgrading to TLS for etcd
[upgrade/staticpods] Preparing for &quot;etcd&quot; upgrade
[upgrade/staticpods] Renewing etcd-server certificate
[upgrade/staticpods] Renewing etcd-peer certificate
[upgrade/staticpods] Renewing etcd-healthcheck-client certificate
[upgrade/staticpods] Moved new manifest to &quot;/etc/kubernetes/manifests/etcd.yaml&quot; and backed up old manifest to &quot;/etc/kubernetes/tmp/kubeadm-backup-manifests-2024-12-23-13-16-26/etcd.yaml&quot;
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
[apiclient] Found 1 Pods for label selector component=etcd
[upgrade/staticpods] Component &quot;etcd&quot; upgraded successfully!
[upgrade/etcd] Waiting for etcd to become available
[upgrade/staticpods] Writing new Static Pod manifests to &quot;/etc/kubernetes/tmp/kubeadm-upgraded-manifests151519974&quot;
[upgrade/staticpods] Preparing for &quot;kube-apiserver&quot; upgrade
[upgrade/staticpods] Renewing apiserver certificate
[upgrade/staticpods] Renewing apiserver-kubelet-client certificate
[upgrade/staticpods] Renewing front-proxy-client certificate
[upgrade/staticpods] Renewing apiserver-etcd-client certificate
[upgrade/staticpods] Moved new manifest to &quot;/etc/kubernetes/manifests/kube-apiserver.yaml&quot; and backed up old manifest to &quot;/etc/kubernetes/tmp/kubeadm-backup-manifests-2024-12-23-13-16-26/kube-apiserver.yaml&quot;
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
[apiclient] Found 1 Pods for label selector component=kube-apiserver
[upgrade/staticpods] Component &quot;kube-apiserver&quot; upgraded successfully!
[upgrade/staticpods] Preparing for &quot;kube-controller-manager&quot; upgrade
[upgrade/staticpods] Renewing controller-manager.conf certificate
[upgrade/staticpods] Moved new manifest to &quot;/etc/kubernetes/manifests/kube-controller-manager.yaml&quot; and backed up old manifest to &quot;/etc/kubernetes/tmp/kubeadm-backup-manifests-2024-12-23-13-16-26/kube-controller-manager.yaml&quot;
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
[apiclient] Found 1 Pods for label selector component=kube-controller-manager
[upgrade/staticpods] Component &quot;kube-controller-manager&quot; upgraded successfully!
[upgrade/staticpods] Preparing for &quot;kube-scheduler&quot; upgrade
[upgrade/staticpods] Renewing scheduler.conf certificate
[upgrade/staticpods] Moved new manifest to &quot;/etc/kubernetes/manifests/kube-scheduler.yaml&quot; and backed up old manifest to &quot;/etc/kubernetes/tmp/kubeadm-backup-manifests-2024-12-23-13-16-26/kube-scheduler.yaml&quot;
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
[apiclient] Found 1 Pods for label selector component=kube-scheduler
[upgrade/staticpods] Component &quot;kube-scheduler&quot; upgraded successfully!
[upload-config] Storing the configuration used in ConfigMap &quot;kubeadm-config&quot; in the &quot;kube-system&quot; Namespace
[kubelet] Creating a ConfigMap &quot;kubelet-config&quot; in namespace kube-system with the configuration for the kubelets in the cluster
[upgrade] Backing up kubelet config file to /etc/kubernetes/tmp/kubeadm-kubelet-config23595869/config.yaml
[kubelet-start] Writing kubelet configuration to file &quot;/var/lib/kubelet/config.yaml&quot;
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials
[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token
[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy

[upgrade/successful] SUCCESS! Your cluster was upgraded to &quot;v1.28.15&quot;. Enjoy!

[upgrade/kubelet] Now that your control plane is upgraded, please proceed with upgrading your kubelets if you haven&#39;t already done so.</code></pre><p>로그에 출력되듯이, 컨트롤 플레인의 업그레이드 과정에는 Static Pod(kube-apiserver, etcd, kube-controller-manager, kube-scheduler) Yaml을 수정하는 과정이 있기 때문에 메이저 버전을 하나 건너 뛰어 업그레이드가 불가능한 것이다.</p>
<h3 id="4-kubelet-kubectl-upgrade">4. Kubelet, Kubectl Upgrade</h3>
<p><code>kubectl, kubelet</code>은 수동으로 업그레이드 해주어야 한다. </p>
<pre><code>yum install -y kubelet kubectl --disableexcludes=kubernetes
systemctl restart kubelet</code></pre><h3 id="5-버전-확인">5. 버전 확인</h3>
<pre><code>$ kubectl get no
NAME           STATUS   ROLES           AGE    VERSION
kube-master    Ready    control-plane   553d   v1.28.15
kube-worker1   Ready    &lt;none&gt;          552d   v1.27.3
kube-worker2   Ready    &lt;none&gt;          2d3h   v1.27.3</code></pre><h3 id="6-워커-노드">6. 워커 노드</h3>
<p>워커 노드 업데이트도 <code>1, 2</code> 까지 동일하고 <code>3</code>에서 <code>kubeadm upgrade apply</code> 가 아닌 <code>kubeadm upgrade node</code> 를 입력한다.</p>
<pre><code>$ kubeadm upgrade node
[upgrade] Reading configuration from the cluster...
[upgrade] FYI: You can look at this config file with &#39;kubectl -n kube-system get cm kubeadm-config -o yaml&#39;
[preflight] Running pre-flight checks
[preflight] Skipping prepull. Not a control plane node.
[upgrade] Skipping phase. Not a control plane node.
[upgrade] Skipping phase. Not a control plane node.
[upgrade] Backing up kubelet config file to /etc/kubernetes/tmp/kubeadm-kubelet-config507561166/config.yaml
[kubelet-start] Writing kubelet configuration to file &quot;/var/lib/kubelet/config.yaml&quot;
[upgrade] The configuration for this node was successfully updated!
[upgrade] Now you should go ahead and upgrade the kubelet package using your package manager.</code></pre><p>그리고 <code>4</code>를 진행하면 된다.</p>
<pre><code>yum install -y kubelet kubectl --disableexcludes=kubernetes
systemctl restart kubelet</code></pre><h3 id="7-버전-확인">7. 버전 확인</h3>
<pre><code>$ kubectl get no
NAME           STATUS   ROLES           AGE    VERSION
kube-master    Ready    control-plane   553d   v1.28.15
kube-worker1   Ready    &lt;none&gt;          552d   v1.28.15
kube-worker2   Ready    &lt;none&gt;          2d3h   v1.28.15</code></pre><h3 id="8-1--7-반복">8. 1 ~ 7 반복</h3>
<p>컨트롤 플레인은 1 ~ 4 과정을 반복해 v1.31 까지 업그레이드해주면 된다.</p>
<p>워커 노드는 메이저 버전을 건너뛰어서 업그레이드할 수 있으므로 컨트롤 플레인 업그레이드 종료 이후에 해도 괜찮으나, 혹시 모를 버전 간 차이 오류 때문에 컨트롤 플레인과 같이 업그레이드하는 것을 권장한다.</p>
<pre><code>$ kubectl get no
NAME           STATUS   ROLES           AGE    VERSION
kube-master    Ready    control-plane   553d   v1.31.4
kube-worker1   Ready    &lt;none&gt;          552d   v1.31.4
kube-worker2   Ready    &lt;none&gt;          2d3h   v1.31.4</code></pre><h3 id="참고">참고</h3>
<ul>
<li><a href="https://kodekloud.com/community/t/etcd-backup-what-is-the-use-of-ca-crt-and-server-key/219076">https://kodekloud.com/community/t/etcd-backup-what-is-the-use-of-ca-crt-and-server-key/219076</a></li>
<li><a href="https://etcd.io/docs/v3.6/op-guide/security/">https://etcd.io/docs/v3.6/op-guide/security/</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform + AWS로 웹 서비스 인프라 구축하기 (3)]]></title>
            <link>https://velog.io/@dong-gwan/Terraform-AWS%EB%A1%9C-%EC%9B%B9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-3</link>
            <guid>https://velog.io/@dong-gwan/Terraform-AWS%EB%A1%9C-%EC%9B%B9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-3</guid>
            <pubDate>Thu, 19 Dec 2024 09:55:49 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하며">시작하며</h2>
<p>저번 글에서는 EC2, EKS, RDS까지 만들어보았으니 이번엔 웹, 애플리케이션을 배포해볼 차례이다.</p>
<p>아래 링크에서 RDS DB와 연결돼 EKS에 배포될 Spring Boot Sample APP과 CloudFront + S3로 정적 웹 호스팅할 VueJS Sample Project를 받았다.</p>
<ul>
<li><a href="https://spring.io/guides/gs/accessing-data-mysql">https://spring.io/guides/gs/accessing-data-mysql</a></li>
<li><a href="https://vuejs.org/guide/quick-start">https://vuejs.org/guide/quick-start</a></li>
</ul>
<h2 id="kubectl-eksctl-helm">kubectl, eksctl, helm</h2>
<p>저번 시간에 생성한 EC2 배스천 호스트에 <code>kubectl, eksctl, helm</code> 명령어를 받아야 한다.</p>
<p><code>kubectl</code>은 Kubernetes API와 통신 가능한 명령어 도구이고, eksctl은 EKS 생성, 관리에 관한 도구, <code>helm</code>은 쿠버네티스 네이티브 패키지 매니저라고 생각하면 된다.</p>
<p>아래 링크를 통해 모두 설치해주자.</p>
<ul>
<li><a href="https://docs.aws.amazon.com/ko_kr/eks/latest/userguide/install-kubectl.html#kubectl-install-update">https://docs.aws.amazon.com/ko_kr/eks/latest/userguide/install-kubectl.html#kubectl-install-update</a></li>
<li><a href="https://helm.sh/docs/intro/install">https://helm.sh/docs/intro/install</a></li>
<li><a href="https://eksctl.io/installation/">https://eksctl.io/installation/</a></li>
</ul>
<p><code>kubectl</code> 명령어를 설치했다면 아래의 명령어로 EKS Kubernetes API와의 통신 정보를 Kubeconfig 파일로 가져오자.</p>
<p><code>aws eks update-kubeconfig --region ap-northeast-2 --name eks-cluster-ap-northeast-2-app</code></p>
<p>해당 config 파일은 홈 디렉토리의 <code>.kube/config</code>로 저장된다.</p>
<p>config 파일을 저장했으면 <code>kubectl get po -A</code>을 입력해 정상적으로 통신이 되는지를 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/c6d1991a-374a-4fa9-a3d2-aeee035220d3/image.png" alt=""></p>
<h2 id="argocd">ArgoCD</h2>
<p>EKS에 애플리케이션을 배포할 방법으로 ArgoCD을 사용해보자.</p>
<p><a href="https://argo-cd.readthedocs.io/en/stable/">ArgoCD</a>란 Gitops 패턴을 구현한 Continuous Deployment 도구이며, 쉽게 생각하여 깃으로 쿠버네티스에 배포되는 모든 리소스, 오브젝트를 관리한다고 생각하면 된다.</p>
<p>ArgoCD는 CNCF Graduated Project로 사용성과 안정성을 검증받아 쿠버네티스를 사용하는 많은 회사에서 사용하고 있는 오픈 소스 솔루션이다.</p>
<p>공식 문서에 나와있는 대로 배포하자.</p>
<pre><code>kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml</code></pre><p><img src="https://velog.velcdn.com/images/dong-gwan/post/495defec-6bb8-408c-92bf-59142e6c0ea8/image.png" alt=""></p>
<p>배포가 다 되었다면 <code>kubectl get po -n argocd</code>을 입력해 정상적으로 파드가 실행되고 있는지 확인하자.
<img src="https://velog.velcdn.com/images/dong-gwan/post/dd7b7c36-b12a-4997-b43b-72d0ed475e92/image.png" alt=""></p>
<p>정상적으로 실행되는 것을 확인했다면 이제 ArgoCD 웹으로 접속할텐데 그러기 위해서는 몇 가지 단계를 거쳐야 한다.</p>
<ol>
<li>Service Type : LoadBalancer? Ingress?</li>
</ol>
<ul>
<li>서비스 유형을 로드 밸런서로 생성해 외부에 오픈할지, 인그레스를 사용할 지를 정해야 하는데 쉽게 로드밸런서는 L4, 인그레스는 L7이라고 생각하면 된다.
ArgoCD 앱에서 TLS 설정을 해주겠다면 로드 밸런서로, 앱에서 안하고 인그레스에서 TLS 설정을 하겠다면 인그레스를 선택하면 된다.
둘다 가능하다면, 인그레스를 추천한다. 인그레스가 호스트, 라우팅 관련된 설정이 더 많기 때문이다.</li>
</ul>
<ol start="2">
<li>Application LoadBalancer Controller</li>
</ol>
<ul>
<li>인그레스를 사용하기로 정했다면 인그레스 클래스로 AWS의 ALB를 이용할 것인데, 인그레스를 생성할 때 동적으로 ALB를 생성해 연결시키려면 Application LoadBalancer Controller를 설치해야 한다.</li>
</ul>
<p>아래 문서를 참고해 설치하자.</p>
<ul>
<li><a href="https://docs.aws.amazon.com/eks/latest/userguide/lbc-helm.html">https://docs.aws.amazon.com/eks/latest/userguide/lbc-helm.html</a></li>
<li><a href="https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/">https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/</a></li>
</ul>
<p>설치가 완료되었다면 <code>kubectl get po -n kube-system</code> 명령어로 ALC 파드가 정상인지 확인해보자</p>
<pre><code>[dgyoon@ip-10-10-1-141 ~]$ kubectl get po -n kube-system
NAME                                            READY   STATUS    RESTARTS   AGE
aws-load-balancer-controller-7f784cc58d-4frjd   1/1     Running   0          6m10s
aws-load-balancer-controller-7f784cc58d-6k88w   1/1     Running   0          6m10s</code></pre><p>이제 <code>ingress.yaml</code>을 작성하자</p>
<pre><code>apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-server-ingress
  namespace: argocd
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: &#39;[{&quot;HTTP&quot;: 80}]&#39;
    alb.ingress.kubernetes.io/target-type: instance
    alb.ingress.kubernetes.io/healthcheck-protocol: HTTP
spec:
  ingressClassName: alb
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: argocd-server
            port:
              number: 80</code></pre><p>상세한 명세서는 공식 문서에 전부 나와있고 이 문서만 설명하자면 일단 어노테이션으로 해당 인그레스가 사용할 ALB의 요구 조건을 작성한다.</p>
<p>우리는 인터넷 웹으로 접근할 것이기 때문에 <code>alb.ingress.kubernetes.io/scheme: internet-facing</code>를 추가하고, 헬스체크, 포트는 80 포트로 오픈한다.</p>
<p><code>alb.ingress.kubernetes.io/target-type</code>는 인스턴스 유형과 파드 IP 유형 두 가지의 선택지가 있는데 파드 IP는 주로 Fargate에서 사용한다고 하므로 우리는 <code>instance</code>로 선택한다.</p>
<p><em>*\</em> 인스턴스 유형의 경우 공개하려는 서비스를 ClusterIP가 아닌 NodePort로 오픈해주어야 한다. * **</p>
<p>아래 <code>ingressClassName</code>에는 <code>alb</code>를 넣어주고 하위 스펙들은 기본적으로 <code>/*</code> 경로로 들어온 트래픽을 어느 서비스로 넘겨줄 지 정한다.</p>
<p>ACM과 Route53으로 도메인 SSL 인증서를 발급받은 뒤 어노테이션 <code>alb.ingress.kubernetes.io/certificate-arn</code>에 인증서 ARN을 채워주면 <code>https</code>로 접근 가능하나 이번 실습에서는 생략했다.</p>
<p>ArgoCD 앱에서 TLS 설정을 안하기로 한 경우 관련 컨피그맵을 수정해주어야 한다.</p>
<pre><code>kubectl patch cm -n argocd argocd-cmd-params-cm --type merge -p &#39;{&quot;data&quot;:{&quot;server.insecure&quot;:&quot;true&quot;}}&#39;</code></pre><p>그리고 <code>kubectl rollout restart deploy argocd-server -n argocd</code> 명령어로 서버를 재시작해주자.</p>
<p>잠시 뒤 ALB가 생성되고 인그레스에 할당된 도메인이 나오면 ArgoCD 웹에 접속할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/03966963-eed2-4c37-a14e-52a8c9760486/image.png" alt=""></p>
<h2 id="spring-boot-sample-app-배포">Spring Boot Sample App 배포</h2>
<p>쿠버네티스에 배포하기 위해선 먼저 앱을 컨테이너 이미지로 패키징해야한다.</p>
<p>그리고 컨테이너 이미지를 이미지 저장소에 Push 하여 사용한다.</p>
<p>Terraform에 아래 코드를 추가하고 <code>apply</code>하여 ECR 이미지 저장소를 만들자.</p>
<pre><code>resource &quot;aws_ecr_repository&quot; &quot;ecr_repository&quot; {
  name = &quot;dgyoon/springboot&quot;
  force_delete = true
}</code></pre><p><code>application.properties</code>에 <code>username, password</code>를 Terraform으로 RDS를 생성할 때 정의한 정보들을 넣어주고, <code>url</code>에는 RDS 클러스터의 라이터 엔드포인트를 넣어준다.</p>
<pre><code>spring.jpa.hibernate.ddl-auto=update
spring.datasource.url=jdbc:mysql://localhost:3306/mydatabase
spring.datasource.username=myuser
spring.datasource.password=secret
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.show-sql: true</code></pre><p>그리고 배스천 호스트로 접속해 RDS로 연결한 후 <code>mydatabase</code> 데이터베이스를 생성해준다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/55090dc3-99d4-4a1b-b737-c8594b59fc71/image.png" alt=""></p>
<p>이 후 Sample App을 jar 파일로 빌드한다.</p>
<pre><code>./gradlew clean build </code></pre><p>그리고 코드의 최상단 경로에 아래 <code>Dockerfile</code>을 생성한다.</p>
<pre><code>FROM openjdk:17.0.2

WORKDIR /springboot

COPY ./build/libs/accessing-data-mysql-0.0.1-SNAPSHOT.jar app.jar

EXPOSE 8080

CMD [&quot;java&quot;,&quot;-jar&quot;,&quot;app.jar&quot;]</code></pre><p>그리고 컨테이너 이미지를 빌드한다.</p>
<pre><code>docker build -t [ECR_REPO_ADDRESS]/dgyoon/springboot:latest . </code></pre><p>ECR 인증을 마치고 이미지를 Push 한다.</p>
<pre><code>aws ecr get-login-password | docker login --username AWS --password-stdin [ECR_REPO_ADDRESS]

docker push [ECR_REPO_ADDRESS]/dgyoon/springboot:latest</code></pre><p>그리고 ArgoCD가 리소스를 생성할 수 있게끔 명시한 Manifest 파일을 생성한다.</p>
<pre><code>apiVersion: apps/v1
kind: Deployment
metadata:
  name: springboot
  namespace: default
  labels:
    app: springboot
spec:
  selector:
    matchLabels:
      app: springboot
  replicas: 1
  template:
    metadata:
      labels:
        app: springboot
    spec:
      containers:
      - name: springboot
        image: &lt;IMAGE_NAME:TAG&gt;
        imagePullPolicy: IfNotPresent
        resources:
          requests:
            cpu: 100m
            memory: 100Mi
          limits:
            cpu: 100m
            memory: 100Mi
---            
apiVersion: v1
kind: Service
metadata:
  name: springboot
spec:
  selector:
    app: springboot
  type: NodePort
  ports:
  - name: springboot
    protocol: TCP
    port: 8080
    targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: springboot-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: &#39;[{&quot;HTTP&quot;: 8080}]&#39;
    alb.ingress.kubernetes.io/target-type: instance
    alb.ingress.kubernetes.io/healthcheck-protocol: HTTP
spec:
  ingressClassName: alb
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: springboot
            port:
              number: 8080</code></pre><p>여기서는 <code>Deployment, Service, Ingress</code> 리소스만 생성한다.</p>
<p>그리고 이 파일을 Github Repo에 업로드하고 ArgoCD에서 그 Repo와 연결해준다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/dd2f5ca3-08c6-4029-b5f9-f297200db21d/image.png" alt=""></p>
<p><code>Application → NEW APP</code> 후 아래처럼 명시하고 <code>CREATE</code>하면 Application이 생성된다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/63b5c601-852f-4800-89a7-96c28e7a4db8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/5a60ff9b-24d4-4e2d-862b-968743ec900b/image.png" alt=""></p>
<p><code>Out of Sync</code> 상태이니 <code>Sync</code>를 한번 해주면 아래와 같이 파드, 서비스, 인그레스가 배포된다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/f407d250-4775-4211-9fb9-c5c3c38d772b/image.png" alt="">
<img src="https://velog.velcdn.com/images/dong-gwan/post/982c5779-1ee3-4e46-b3e9-9d11d9f724b1/image.png" alt=""></p>
<p>문서에 적혀있는대로 인그레스의 도메인에 <code>/demo/all</code> 붙여 입력해주면 아래와 출력된다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/95917039-e799-4a6d-89b7-9e850b1cc782/image.png" alt=""></p>
<p>그리고 CURL로 API 요청을 보내고 다시 <code>/demo/all</code> 을 확인해보면 추가되어 있는 모습을 볼 수 있다.</p>
<pre><code>$ curl http://[API_INGRESS_DOMAIN]/demo/add -d name=First -d email=someemail@someemailprovider.com
SAVED</code></pre><p><img src="https://velog.velcdn.com/images/dong-gwan/post/1c07588f-81d5-4ba3-83f5-d358dfb22a42/image.png" alt=""></p>
<p>배스천 호스트에 접속해 RDS DB로 접속 후 확인해보면 레코드가 추가된 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/ed1b7999-f5ff-44cb-85e5-b4e579c4a407/image.png" alt=""></p>
<p>이렇게 RDS와 연결된 Spring Boot APP을 EKS에 배포해 API 애플리케이션 서버를 만들어보았다.</p>
<h2 id="vuejs-spa">VueJS SPA</h2>
<p>이제는 SPA(Single Page Application) Sample을 CloudFront와 S3로 배포해보자.</p>
<p>Terraform으로 CloudFront와 S3 버킷부터 먼저 만들어보자.</p>
<p>지금까지 했듯이 s3 폴더를 만들어 <code>main, variable, output.tf</code> 파일을 작성한다.</p>
<p><strong>modules/s3/main.tf</strong></p>
<pre><code>resource &quot;aws_s3_bucket&quot; &quot;s3_bucket&quot; {
  bucket = var.bucket_name
}</code></pre><p><strong>modules/s3/variable.tf</strong></p>
<pre><code>variable &quot;bucket_name&quot; {
  type = string
}</code></pre><p><strong>modules/s3/output.tf</strong></p>
<pre><code>output &quot;bucekt_domain_name&quot; {
  value = aws_s3_bucket.s3_bucket.bucket_domain_name
}
output &quot;bucket_name&quot; {
  value = aws_s3_bucket.s3_bucket.bucket
}
output &quot;bucket_id&quot; {
  value = aws_s3_bucket.s3_bucket.id
}</code></pre><p>그리고 cloudfront 모듈도 작성하자.
<strong>modules/cloudfront/main.tf</strong></p>
<pre><code>resource &quot;aws_cloudfront_distribution&quot; &quot;cloudfront_distribution&quot; {
  origin {
    domain_name = var.domain_name
    origin_id = var.bucket_id
    origin_access_control_id = aws_cloudfront_origin_access_control.s3.id
  }
  default_cache_behavior {
    allowed_methods  = [&quot;GET&quot;, &quot;HEAD&quot;]
    cached_methods   = [&quot;GET&quot;, &quot;HEAD&quot;]
    target_origin_id = var.bucket_id
    viewer_protocol_policy = &quot;allow-all&quot;
    cache_policy_id = data.aws_cloudfront_cache_policy.cacheoptimized.id
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
  restrictions {
    geo_restriction {
      restriction_type = &quot;none&quot;
    }
  }
  enabled = true
  default_root_object = &quot;index.html&quot;
  depends_on = [ aws_cloudfront_origin_access_control.s3 ]
}

resource &quot;aws_cloudfront_origin_access_control&quot; &quot;s3&quot; {
  name = &quot;cloudfront_s3_oac&quot;
  origin_access_control_origin_type = &quot;s3&quot;
  signing_behavior = &quot;always&quot;
  signing_protocol = &quot;sigv4&quot;
}

data &quot;aws_cloudfront_cache_policy&quot; &quot;cacheoptimized&quot; {
  name = &quot;Managed-CachingOptimized&quot;
}

resource &quot;aws_s3_bucket_policy&quot; &quot;allow_cloudfront_access&quot; {
  bucket = var.bucket_id
  policy = jsonencode({
        &quot;Version&quot;: &quot;2008-10-17&quot;,
        &quot;Id&quot;: &quot;PolicyForCloudFrontPrivateContent&quot;,
        &quot;Statement&quot;: [
            {
                &quot;Sid&quot;: &quot;AllowCloudFrontServicePrincipal&quot;,
                &quot;Effect&quot;: &quot;Allow&quot;,
                &quot;Principal&quot;: {
                    &quot;Service&quot;: &quot;cloudfront.amazonaws.com&quot;
                },
                &quot;Action&quot;: &quot;s3:GetObject&quot;,
                &quot;Resource&quot;: &quot;arn:aws:s3:::${var.bucket_name}/*&quot;,
                &quot;Condition&quot;: {
                    &quot;StringEquals&quot;: {
                      &quot;AWS:SourceArn&quot;: &quot;${aws_cloudfront_distribution.cloudfront_distribution.arn}&quot;
                    }
                }
            }
        ]
      })
}</code></pre><p><strong>modules/cloudfront/variable.tf</strong></p>
<pre><code>variable &quot;domain_name&quot; {
  type = string
}
variable &quot;bucket_id&quot; {
  type = string
}
variable &quot;bucket_name&quot; {
  type = string
}</code></pre><p>루트 모듈에서 CloudFront와 S3를 생성해주자.</p>
<pre><code>...
module &quot;s3_web&quot; {
  source = &quot;./modules/s3&quot;
  bucket_name = &quot;sample-dgyoon-web-bucket&quot;
}

module &quot;cloudfront&quot; {
  source = &quot;./modules/cloudfront&quot;
  bucket_id = module.s3_web.bucket_id
  domain_name = module.s3_web.bucekt_domain_name
  bucket_name = module.s3_web.bucket_name
}
...</code></pre><p>그리고 VueJS 프로젝트를 생성하고 배포할 수 있게 빌드까지 하자.</p>
<pre><code>npm create vue@latest
cd vue-project
npm install
npm run build</code></pre><p>이렇게 하고나면 <code>dist</code> 폴더에 <code>index.html</code>과 <code>js, css</code> 파일이 생길텐데 이 파일들을 전부 방금 생성한 S3 버킷에 전송한다.</p>
<pre><code>cd dist
aws s3 cp ./ s3://sample-dgyoon-web-bucket</code></pre><p>그리고 잠시 후 콘솔에 접속해 생성된 CloudFront의 배포 도메인에 접속하면 아래와 같이 나올 것이다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/79bc73fa-c568-49b4-9efc-7b669723051c/image.png" alt=""></p>
<p>이렇게 VueJS SPA Sample 웹을 배포해보았다.</p>
<h2 id="마치며">마치며</h2>
<p>최종적으로 완성하게 된 설계도 되겠다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/d5c4fccc-cfe2-4681-bb8b-2c61e8fd6a9b/image.png" alt=""></p>
<p>눈치챈 분도 있겠지만, 주로 EKS에 배포되는 애플리케이션이 백엔드 개발 API 서버가 되고, CloudFront를 통해 배포되는 웹이 프론트엔드 개발 웹 프로젝트가 되겠다. </p>
<p>해당 웹에서 API 서버의 인그레스 도메인을 통해 서로 통신하고 렌더링하면 하나의 웹 서비스 환경을 구축하게 되는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform + AWS로 웹 서비스 인프라 구축하기 (2)]]></title>
            <link>https://velog.io/@dong-gwan/Terraform-AWS%EB%A1%9C-%EC%9B%B9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-2</link>
            <guid>https://velog.io/@dong-gwan/Terraform-AWS%EB%A1%9C-%EC%9B%B9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-2</guid>
            <pubDate>Tue, 17 Dec 2024 13:05:03 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하며">시작하며</h2>
<p>이번 글에서는 Terraform으로 EC2 인스턴스(Bastion), EKS 클러스터, RDS 클러스터까지 생성해보자.</p>
<h2 id="설계도">설계도</h2>
<p>이번 글에서 완성할 설계도는 이렇다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/b8ff423e-afc0-46d5-8c94-70cb987b25fc/image.png" alt=""></p>
<h2 id="ec2-모듈">EC2 모듈</h2>
<p>VPC, Subnet을 모듈화하여 생성하였듯이 EC2 리소스도 모듈화해보자. </p>
<p>핵심은 재사용성을 생각해서 구성해야 하며 이번에 생성하고자 하는 인스턴스는 배스천 호스트로 사용할 인스턴스이지만 다른 용도의 인스턴스여도 같은 모듈을 사용해 생성할 수 있어야 할 것이다.</p>
<p>아래와 같이 <code>main.tf</code>를 작성한다.</p>
<p><strong>modules/ec2/main.tf</strong></p>
<pre><code>data &quot;aws_ami&quot; &quot;amazon_linux_2023_latest&quot; {
  most_recent = true
  filter {
    name = &quot;name&quot;
    values = [&quot;al2023-ami-*&quot;]
  }
  filter {
    name = &quot;virtualization-type&quot;
    values = [&quot;hvm&quot;]
  }
  filter {
    name = &quot;architecture&quot;
    values = [ &quot;x86_64&quot; ]
  }
  owners = [ &quot;amazon&quot; ]
}

data &quot;aws_subnet&quot; &quot;ec2_subnet&quot; {
  id = var.subnet_id
}

resource &quot;aws_key_pair&quot; &quot;ec2_key_pair&quot; {
  key_name = &quot;ec2-key-pair&quot;
  public_key = var.public_key
}

resource &quot;aws_instance&quot; &quot;ec2_instance&quot; {
  ami = data.aws_ami.amazon_linux_2023_latest.id
  instance_type = var.instance_type
  subnet_id = var.subnet_id
  key_name = aws_key_pair.ec2_key_pair.key_name
  vpc_security_group_ids = [ aws_security_group.ec2_security_group.id ]
  tags = {
    Name = &quot;ec2-${data.aws_subnet.ec2_subnet.availability_zone}-${var.instance_name_suffix}&quot;
  }
  depends_on = [ aws_security_group.ec2_security_group ]
}

resource &quot;aws_security_group&quot; &quot;ec2_security_group&quot; {
  vpc_id = var.vpc_id
  name = &quot;sgr-ec2-${data.aws_subnet.ec2_subnet.availability_zone}-${var.instance_name_suffix}&quot;
}
resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;allow_ssh&quot; {
  security_group_id = aws_security_group.ec2_security_group.id
  cidr_ipv4 = &quot;0.0.0.0/0&quot;
  from_port = 22
  ip_protocol = &quot;tcp&quot;
  to_port = 22
}
resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;allow_all&quot; {
  security_group_id = aws_security_group.ec2_security_group.id
  cidr_ipv4 = &quot;0.0.0.0/0&quot;
  ip_protocol = -1
}</code></pre><p>먼저 최상단 <code>data</code> 블록은 Terraform으로 생성 및 유지 관리하지 않는 리소스의 정보를 가져오는 블록이다.</p>
<p>AWS에 이미 존재하는 <code>&quot;aws_ami&quot;</code> 리소스를 가져와 EC2 인스턴스를 생성하기 위함이며, 이번에 사용할 AMI는 아마존 리눅스 2023 중 가장 최신 버전이다.</p>
<p>그 후 인스턴스가 배치될 서브넷의 <code>id</code>를 받고 해당 서브넷의 AZ까지 data 블록으로 가져온다.</p>
<p>그리고 EC2 인스턴스에 SSH 연결을 위한 SSH 퍼블릭 키를 외부에서 주입받는다.</p>
<p><code>resource &quot;aws_instance&quot;</code> 블록으로 EC2 인스턴스의 세부 항목들을 정의한다.</p>
<p><code>ami</code>는 위에서 <code>data</code> 블록으로 가져온 이미지의 <code>id</code>를 넣고 인스턴스 유형, 서브넷, SSH 키, 보안 그룹, 태그까지 정의한다.</p>
<p><code>depends_on</code> 은 리소스의 의존성, 즉 생성/삭제 순서를 직접 정의하는 것인데 기본적으로 Terraform은 리소스 간의 생성 의존도를 필요에 따라 어느 정도 순서대로 생성하나 간혹 직접 명시를 해줘야 할 필요가 있다.</p>
<p>여기서 명시한 이유는 추후 <code>terraform destroy</code>로 생성한 리소스들을 제거할 때, <code>depends_on</code>으로 정의한 보안 그룹을 먼저 지워주지 않으면, EC2 인스턴스보다 보안 그룹을 먼저 제거하게 되는데 EC2 인스턴스가 보안 그룹을 사용 중이라 삭제되지 못하게 된다.</p>
<p><strong>depends_on이 없는 terraform destroy</strong>
<img src="https://velog.velcdn.com/images/dong-gwan/post/ef8b389f-3a11-41f4-8469-774687b59576/image.png" alt=""></p>
<blockquote>
<p>보안 그룹을 삭제하려하나 EC2 인스턴스가 아직 지워지지 않고 사용 중이라 삭제 상태에 갇힘.</p>
</blockquote>
<p><strong>depends_on이 있는 terraform destroy</strong>
<img src="https://velog.velcdn.com/images/dong-gwan/post/9c778641-64ae-4bd1-8804-572433793106/image.png" alt=""></p>
<blockquote>
<p>EC2 인스턴스가 먼저 삭제된 후 보안 그룹 삭제</p>
</blockquote>
<p>그리고 <code>resource &quot;aws_security_group&quot;</code>, <code>resource &quot;aws_security_group_ingress_rule&quot;</code>, <code>&quot;aws_security_group_egress_rule&quot;</code>로 SSH 연결을 허용하는 보안 그룹을 정의해준다.</p>
<p><code>main.tf</code>에 사용된 변수들을 정의하는 <code>variable.tf</code>는 아래와 같다.</p>
<p><strong>modules/ec2/variable.tf</strong></p>
<pre><code>variable &quot;public_key&quot; {
  type = string
}
variable &quot;instance_type&quot; {
  type = string
}
variable &quot;subnet_id&quot; {
  type = string
}
variable &quot;vpc_id&quot; {
  type = string
}
variable &quot;instance_name_suffix&quot; {
  type = string
}</code></pre><p>그 후 루트 모듈의 <code>main.tf</code>에서 EC2 모듈을 호출한다.
<strong>main.tf</strong></p>
<pre><code>...
module &quot;ec2_bastion&quot; {
  source               = &quot;./modules/ec2&quot;
  instance_type        = &quot;t2.micro&quot;
  subnet_id            = module.public_subnet.subnet_id[keys(local.public_subnet_cidr_blocks)[0]]
  public_key           = var.public_key
  vpc_id               = module.vpc.vpc_id
  instance_name_suffix = &quot;bastion&quot;
}
...</code></pre><p>variable.tf 파일에 public_key 변수를 정의하고 <code>terraform.tfvars</code> 파일에 자신의 SSH 공개키를 주입해준다.</p>
<p><strong>variable.tf</strong></p>
<pre><code>...
variable &quot;public_key&quot; {
  type = string
}
...</code></pre><p><strong>terraform.tfvars</strong></p>
<pre><code>...
public_key=&quot;ssh-rsa XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&quot;
...</code></pre><p><code>terraform init</code> → <code>terrafomr apply</code> → <code>yes</code> 하면 EC2 인스턴스가 생성되고 해당 인스턴스에 SSH 연결을 시도하면 아래와 같이 연결된다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/88f66e80-f1fa-42e7-b692-589fde169ac6/image.png" alt=""></p>
<blockquote>
<p>실습 후 바로 삭제할 인스턴스라 IP를 가리지 않았지만, 평소에는 IP가 노출되지 않게 주의해야 한다.</p>
</blockquote>
<h2 id="eks-모듈">EKS 모듈</h2>
<p>EKS 리소스는 생성에 필요한 인자가 많아 고려해야 할 점도 많기 때문에 아래 문서를 꼭 참고해 필요한 인자가 무엇인지 파악해야 한다.</p>
<ul>
<li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eks_cluster">https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eks_cluster</a></li>
</ul>
<p>바로 <code>main.tf</code>를 살펴보자.</p>
<p><strong>modules/eks/main.tf</strong></p>
<pre><code>resource &quot;aws_eks_cluster&quot; &quot;eks_cluster&quot; {
  name = var.cluster_name
  role_arn = aws_iam_role.eks_cluster.arn
  vpc_config {
    subnet_ids = var.subnet_list
    security_group_ids = [ aws_security_group.eks_cluster_security_group.id ]
    endpoint_private_access = var.endpoint_private_access
    endpoint_public_access = var.endpoint_public_access
  }
  access_config {
    authentication_mode = &quot;API&quot;
  }
}

resource &quot;aws_eks_node_group&quot; &quot;eks_node_group&quot; {
  cluster_name = aws_eks_cluster.eks_cluster.name
  node_group_name = var.node_group_name
  node_role_arn = aws_iam_role.eks_node_group.arn
  subnet_ids = var.subnet_list
  capacity_type = &quot;ON_DEMAND&quot;
  scaling_config {
    desired_size = var.scaling_desired_size
    max_size = var.scaling_max_size
    min_size = var.scaling_min_size
  }
  instance_types = [ var.node_group_instance_type ]
  depends_on = [ aws_iam_role.eks_node_group ]
}

data &quot;aws_iam_policy_document&quot; &quot;eks_assume_role&quot; {
  statement {
    effect = &quot;Allow&quot;

    principals {
      type        = &quot;Service&quot;
      identifiers = [&quot;eks.amazonaws.com&quot;]
    }

    actions = [&quot;sts:AssumeRole&quot;]
  }
}

resource &quot;aws_iam_role&quot; &quot;eks_cluster&quot; {
  name = &quot;eks-cluster-role&quot;
  assume_role_policy = jsonencode({
    Version = &quot;2012-10-17&quot;
    Statement = [
      {
        Action = [
          &quot;sts:AssumeRole&quot;,
          &quot;sts:TagSession&quot;
        ]
        Effect = &quot;Allow&quot;
        Principal = {
          Service = &quot;eks.amazonaws.com&quot;
        }
      },
    ]
  })
}

resource &quot;aws_iam_role_policy_attachment&quot; &quot;eks_cluster_AmazonEKSClusterPolicy&quot; {
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonEKSClusterPolicy&quot;
  role       = aws_iam_role.eks_cluster.name
}

resource &quot;aws_iam_role_policy_attachment&quot; &quot;eks_cluster_AmazonEKSVPCResourceController&quot; {
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonEKSVPCResourceController&quot;
  role       = aws_iam_role.eks_cluster.name
}

resource &quot;aws_security_group&quot; &quot;eks_cluster_security_group&quot; {
  name = &quot;sgr-eks-${var.region}-cluster&quot;
  vpc_id = var.vpc_id
}

resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;allow_https_from_bastion&quot; {
  security_group_id = aws_security_group.eks_cluster_security_group.id
  cidr_ipv4 = &quot;${var.bastion_ip}/32&quot;
  from_port = 443
  to_port = 443
  ip_protocol = &quot;tcp&quot;
}

resource &quot;aws_iam_role&quot; &quot;eks_node_group&quot; {
  name = &quot;eks-node-group-role&quot;

  assume_role_policy = jsonencode({
    Statement = [{
      Action = &quot;sts:AssumeRole&quot;
      Effect = &quot;Allow&quot;
      Principal = {
        Service = &quot;ec2.amazonaws.com&quot;
      }
    }]
    Version = &quot;2012-10-17&quot;
  })
}

resource &quot;aws_iam_role_policy_attachment&quot; &quot;eks_node_group_AmazonEKSWorkerNodePolicy&quot; {
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy&quot;
  role       = aws_iam_role.eks_node_group.name
}

resource &quot;aws_iam_role_policy_attachment&quot; &quot;eks_node_group_AmazonEKS_CNI_Policy&quot; {
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy&quot;
  role       = aws_iam_role.eks_node_group.name
}

resource &quot;aws_iam_role_policy_attachment&quot; &quot;eks_node_group_AmazonEC2ContainerRegistryReadOnly&quot; {
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly&quot;
  role       = aws_iam_role.eks_node_group.name
}</code></pre><p>먼저 <code>aws_eks_cluster</code>와 <code>aws_node_group</code> 두 리소스를 생성해야 한다. <code>aws_eks_cluster</code>는 Kubernetes API 엔드포인트를 갖는 관리형 컨트롤 플레인을 생성하고, <code>aws_node_group</code>는 실제로 사용자가 생성하는 Pod가 배치되는 워커 노드(EC2 인스턴스)를 정의한다.</p>
<p><code>resource &quot;aws_eks_cluster&quot;</code> 블록에선 클러스터 이름, IAM Assume Role, 컨트롤 플레인이 배치될 서브넷과 보안 그룹을 정의하고 <code>endpoint_private_access, endpoint_public_access</code>로 API 엔드포인트를 퍼블릭으로 생성할지 프라이빗으로 생성할지를 결정한다.</p>
<p>우리는 배스천 호스트에서 <code>kubectl</code> 명령어로 Kubernetes API와 통신할 것이기 때문에 프라이빗으로 생성한다.</p>
<p>만약 VPC 외부에서(= 로컬에서) <code>kubectl</code>로 통신하고 싶다면 퍼블릭 엔드포인트를 생성해야 한다.</p>
<p>그리고 EKS 접근 권한에 대한 인증 모드를 API로 설정한다.</p>
<p><code>resource &quot;aws_eks_node_group&quot;</code> 블록에서는 소속될 클러스터 이름, 노드 그룹 이름, 노드 그룹의 IAM Assume Role, 서브넷, 온디맨드/스팟 여부, 노드의 최대/최소 수, 인스턴스 유형을 정의하고 전부 루트 모듈에서 주입시키기 위해 변수로 입력받는다.(왜? 모듈화 = 재사용성)</p>
<p>그리고 아래는 <code>aws_eks_cluster</code>와 <code>aws_eks_node_group</code>에 필요한 Assume Role을 생성하도록 <code>aws_iam_role, aws_iam_policy_document, aws_iam_role_policy_attachment</code> 리소스를 공식 문서에 나온대로 정의해준다.</p>
<p>그리고 배스천 호스트만 컨트롤 플레인의 Kubernetes API에 통신이 가능하게끔 보안 그룹을 설정해준다.</p>
<pre><code>cidr_ipv4 = &quot;${var.bastion_ip}/32&quot;
  from_port = 443
  to_port = 443</code></pre><p>EC2 모듈에서 생성한 배스천 호스트의 프라이빗 IP가 필요하므로 EC2 모듈의 <code>output.tf</code>를 작성해준다.</p>
<p><strong>modules/ec2/output.tf</strong></p>
<pre><code>output &quot;private_ip&quot; {
  value = aws_instance.ec2_instance.private_ip
}</code></pre><p>그리고 EKS 모듈의 <code>variable.tf</code>를 아래와 같이 정의해준다.</p>
<pre><code>variable &quot;cluster_name&quot; {
  type = string
}
variable &quot;subnet_list&quot; {
  type = list(string)
}
variable &quot;bastion_ip&quot; {
  type = string
}
variable &quot;node_group_instance_type&quot; {
  type = string
}
variable &quot;scaling_desired_size&quot; {
  type = string
}
variable &quot;scaling_max_size&quot; {
  type = string
}
variable &quot;scaling_min_size&quot; {
  type = string
}
variable &quot;node_group_name&quot; {
  type = string
}
variable &quot;vpc_id&quot; {
  type = string
}
variable &quot;endpoint_private_access&quot; {
  type = bool
}
variable &quot;endpoint_public_access&quot; {
  type = bool
}
variable &quot;region&quot; {
  type = string
}</code></pre><p>최종적으로 루트 모듈에서 EKS 모듈을 호출해 EKS 클러스터와 워커 노드 그룹을 생성해준다.</p>
<pre><code>...
module &quot;eks_cluster&quot; {
  source                   = &quot;./modules/eks&quot;
  cluster_name             = &quot;eks-cluster-ap-northeast-2&quot;
  subnet_list              = values(module.private_subnet.subnet_id)
  bastion_ip               = module.ec2_bastion.private_ip
  node_group_name          = &quot;eks-node-group-t3-medium&quot;
  node_group_instance_type = &quot;t3.medium&quot;
  scaling_desired_size     = 2
  scaling_max_size         = 3
  scaling_min_size         = 2
  vpc_id                   = module.vpc.vpc_id
  region                   = var.region
 endpoint_private_access  = true
  endpoint_public_access   = false
}
...</code></pre><p>아래와 같이 EKS 클러스터가 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/7755bb97-0071-4a3b-94fa-68fa422a4fd0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/4232f118-773d-4673-8526-402a9b684534/image.png" alt=""></p>
<h2 id="rds-모듈">RDS 모듈</h2>
<p>다음은 RDS 모듈을 만들어보자.</p>
<p>Aurora를 사용하거나 Multi AZ Cluster를 사용하려면 <code>aws_rds_cluster</code> 리소스를 만들어야 한다.</p>
<p>우리는 Aurora for MySQL을 사용할 것이기 때문에 아래와 같이 작성해준다.</p>
<p><strong>modules/rds/main.tf</strong></p>
<pre><code>resource &quot;aws_rds_cluster&quot; &quot;rds_cluster&quot; {
  cluster_identifier = var.cluster_identifier
  engine = var.engine
  engine_version = var.engine_version
  availability_zones = var.availability_zones
  db_subnet_group_name = aws_db_subnet_group.rds_subnet_group.name
  master_username = var.master_username
  master_password = var.master_password
  vpc_security_group_ids = [aws_security_group.rds_security_group.id]
  tags = {
    Name = &quot;${var.cluster_identifier}&quot;
  }
  skip_final_snapshot = true
}

resource &quot;aws_rds_cluster_instance&quot; &quot;rds_cluster_instance&quot; {
  count = 2
  identifier = &quot;${aws_rds_cluster.rds_cluster.cluster_identifier}-instance-${count.index}&quot;
  cluster_identifier = aws_rds_cluster.rds_cluster.id
  engine = aws_rds_cluster.rds_cluster.engine
  engine_version = aws_rds_cluster.rds_cluster.engine_version
  instance_class = var.instance_class
  db_subnet_group_name = aws_rds_cluster.rds_cluster.db_subnet_group_name
  tags = {
    Name = &quot;${aws_rds_cluster.rds_cluster.cluster_identifier}-instance-${count.index}&quot;
  }
}

resource &quot;aws_db_subnet_group&quot; &quot;rds_subnet_group&quot; {
  name = &quot;rds-subnet-group-ap-northeast-2&quot;
  subnet_ids = var.subnet_ids
  tags = {
    Name = &quot;rds-subnet-group-ap-northeast-2&quot;
  }
}

resource &quot;aws_security_group&quot; &quot;rds_security_group&quot; {
  vpc_id = var.vpc_id
  name = &quot;sgr-rds-ap-northeast-2&quot;
}

resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;allow_mysql_from_eks&quot; {
  count = length(var.eks_subnet_cidr_block)
  security_group_id = aws_security_group.rds_security_group.id
  cidr_ipv4 = element(var.eks_subnet_cidr_block,count.index)
  from_port = 3306
  ip_protocol = &quot;tcp&quot;
  to_port = 3306
}

resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;allow_mysql_from_bastion&quot; {
  security_group_id = aws_security_group.rds_security_group.id
  cidr_ipv4 = &quot;${var.bastion_private_ip}/32&quot;
  from_port = 3306
  ip_protocol = &quot;tcp&quot;
  to_port = 3306
}</code></pre><p>최상단 두 리소스 블록을 제외하고는 서브넷, 보안 그룹이므로 생략하고, <code>&quot;aws_rds_cluster&quot;</code> 블록에서는 RDS 클러스터 단위의 클러스터명, DB 엔진, AZ, 서브넷, 보안 그룹, 슈퍼유저 계정을 정의해준다.</p>
<p>그리고 <code>&quot;aws_rds_cluster_instance&quot;</code> 블록에서 실제 DB 인스턴스의 수, 이름, 엔진, 인스턴스 유형, 서브넷을 정의해준다.</p>
<p>그리고 루트 모듈의 <strong>main.tf</strong>에 다음과 같이 RDS 모듈을 호출한다.</p>
<pre><code>...
module &quot;rds_cluster&quot; {
  source                = &quot;./modules/rds&quot;
  vpc_id                = module.vpc.vpc_id
  subnet_ids            = [module.private_subnet.subnet_id[&quot;ap-northeast-2b&quot;], module.private_subnet.subnet_id[&quot;ap-northeast-2c&quot;]]
  availability_zones    = [&quot;ap-northeast-2a&quot;, &quot;ap-northeast-2b&quot;, &quot;ap-northeast-2c&quot;]
  cluster_identifier = &quot;rds-cluster-ap-northeast-2&quot;
  engine                = &quot;aurora-mysql&quot;
  engine_version        = &quot;5.7.mysql_aurora.2.11.2&quot;
  master_password       = &quot;dgyoon1!&quot;
  master_username       = &quot;dgyoon&quot;
  eks_subnet_cidr_block = values(module.private_subnet.cidr_block)
  bastion_private_ip    = module.ec2_bastion.bastion_private_ip
}
...</code></pre><p>역시 <code>terraform init</code> → <code>terraform apply</code> → <code>yes</code> 하면 다음과 같이 RDS 클러스터와 인스턴스가 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/9544caa3-a3ad-4200-8add-bb87aa8aacca/image.png" alt=""></p>
<h2 id="마치며">마치며</h2>
<p>이번 글에서는 EC2, EKS, RDS를 모듈화하였고 배스천 호스트와 EKS 클러스터, RDS 클러스터를 생성해보았다.</p>
<p>다음 글에서는 EKS에 ArgoCD를 배포하고 RDS 클러스터와 통신하는 Application 서버를 배포해볼 것이고, CloudFront와 S3를 이용해 SPA 웹 프로젝트를 호스팅하는 것까지 작성해볼 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform + AWS로 웹 서비스 인프라 구축하기 (1)]]></title>
            <link>https://velog.io/@dong-gwan/Terraform-AWS%EB%A1%9C-%EC%9B%B9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-1</link>
            <guid>https://velog.io/@dong-gwan/Terraform-AWS%EB%A1%9C-%EC%9B%B9-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B8%ED%94%84%EB%9D%BC-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-1</guid>
            <pubDate>Sun, 08 Dec 2024 12:22:59 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하며">시작하며</h2>
<p>한 달동안 Terraform으로 신규 AWS 운영 환경을 구성해보는 작업을 맡았었는데, 이를 정리해보려고 한다.</p>
<p>이번 글에서는 VPC와 서브넷까지 만들어보려 한다.</p>
<p>Terraform 설치와 AWS 사용자 인증(.aws/credentials)은 되어있다고 가정하고 시작하자.</p>
<p>최종 소스코드는 아래 깃허브에 업로드 되어있다.(aws 디렉토리)</p>
<ul>
<li><a href="https://github.com/YoonDongGwan/terraform-demo.git">https://github.com/YoonDongGwan/terraform-demo.git</a></li>
</ul>
<h2 id="설계도">설계도</h2>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/0bd77355-79b3-4ca8-b3e9-a6f60002ae60/image.png" alt=""></p>
<p>Terraform을 통해 최종적으로 만들어보려하는 아키텍처이며, 사용하는 리소스는 아래와 같다.</p>
<ul>
<li>VPC</li>
<li>EC2</li>
<li>EKS</li>
<li>RDS</li>
<li>S3</li>
<li>CloudFront</li>
</ul>
<p>CloudFront + S3로 VueJS로 개발한 샘플 SPA(Single Page Application)를 배포하여 웹 호스팅하도록 구성하고 Spring Boot로 개발한 샘플 WAS를 컨테이너로 패키징해 EKS로 배포할 것이며, WAS에 연결될 RDS까지 사용해 볼 것이다.</p>
<h2 id="네이밍-룰">네이밍 룰</h2>
<p>시작하기 전에, 생성할 AWS 리소스에 대해 네이밍 룰을 명확하게 정하고 시작하고 싶었다.</p>
<p>누구나 공감할 것이라 생각되는데, 리소스명 규칙이 없으면 이름 정하는데에만 시간을 꽤나 쏟게 되고, 추후에 이름을 수정하고 싶어도 마음대로 수정하지 못하는 리소스도 있기 때문이다.</p>
<p>여러 블로그와 베스트 프랙티스 등을 찾아보며 내가 정한 리소스 네이밍 룰은 아래와 같다.</p>
<p><code>[리소스명]-[리전/AZ]-[환경]-[public/private]-[용도]</code></p>
<p>여기서 <code>public/private</code> 부분은 서브넷이나 네트워크 관련된 리소스에만 추가하기로 했고, <code>[환경], [용도]</code>는 <code>dev,stg,prd</code>나 <code>app,database,web</code> 등을 의미하는데 이번 포스팅에서는 제외하기로 했다.</p>
<p>그리고 Terraform에서 사용되는 블록 개체명은 <code>-</code>이 아닌 <code>_</code>를 사용하기로 했다.</p>
<p>그 이유는 <a href="https://cloud.google.com/docs/terraform/best-practices/general-style-structure?hl=ko#naming-convention">이 문서</a>에서 권장하는 항목이기 때문...</p>
<h2 id="terraform-module">Terraform Module</h2>
<p>Terraform 시작 전에 어떤 식으로 리소스를 관리하면 좋을 지 찾아봤는데, 모듈화하여 관리하는 것이 베스트 프랙티스로 여겨지는 듯하다.</p>
<p>모듈화하여 관리하는 것에는 아래와 같은 장점이 있다.</p>
<ul>
<li>재사용성 증가(=중복 코드 제거)</li>
<li>리소스별 관리 구별</li>
<li>복잡한 설정 추상화 가능</li>
</ul>
<p>이번 작업에서는 이 모듈 구조를 적용해보기로 했고, 사실 모듈 구조를 이해하는데는 시간이 조금 걸렸다.</p>
<p>모듈화를 하는 것이 베스트 프랙티스이긴 하지만 정답은 아니기에, 결국 자기 입맛에 맞게끔 관리하는 편이 좋다는 생각이 든다.</p>
<p>루트 모듈에 여러 리소스를 파일명으로 구분해서 꼬이지 않고 잘 정리할 수만 있다면 모듈 구조보다 오히려 좋은 점도 많을 것이라고 생각한다.</p>
<p>최종적으로 생성될 Terraform 구조는 아래와 같다.</p>
<pre><code>[dgyoon@terraform aws]# tree
.
├── main.tf
├── modules
│   ├── cloudfront
│   │   ├── main.tf
│   │   ├── output.tf
│   │   └── variable.tf
│   ├── ec2
│   │   ├── main.tf
│   │   ├── output.tf
│   │   └── variable.tf
│   ├── ecr
│   │   └── main.tf
│   ├── eks
│   │   ├── main.tf
│   │   └── variable.tf
│   ├── rds
│   │   ├── main.tf
│   │   └── variable.tf
│   ├── s3
│   │   ├── main.tf
│   │   ├── output.tf
│   │   └── variable.tf
│   ├── subnet
│   │   ├── main.tf
│   │   ├── output.tf
│   │   └── variable.tf
│   └── vpc
│       ├── main.tf
│       ├── output.tf
│       └── variable.tf
├── output.tf
└── variable.tf</code></pre><p>모듈 구조에서는 각 모듈이 폴더별로 구분이 되고, 주로 <code>main.tf</code>, <code>variable.tf</code>, <code>output.tf</code> 파일을 사용한다.</p>
<p><code>main.tf</code>에 리소스 코드가 담기게 되고, <code>variable.tf</code>와 <code>output.tf</code>는 외부에서 리소스 코드에 데이터를 넣어 줄때(variable.tf), 혹은 리소스 코드에서 외부로 데이터를 내보낼 때(output.tf)를 사용하게 된다.</p>
<h2 id="root-모듈">ROOT 모듈</h2>
<p>처음 시작할 때 필요한 최상단 경로(루트 모듈)의 <code>main.tf</code>는 아래와 같다.</p>
<pre><code>terraform {
  required_providers {
    aws = {
      source  = &quot;hashicorp/aws&quot;
      version = &quot;5.80.0&quot;
    }
  }
}

provider &quot;aws&quot; {
  region = &quot;ap-northeast-2&quot;
}</code></pre><p>위 코드는 <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs">Terraform AWS 문서</a>에 그대로 기술되어 있는 코드이다.</p>
<p>위 링크를 타고 들어가 우측 <code>USE PROVIDER</code>를 클릭해 코드를 복사해오면 된다.</p>
<p>terraform 블록에 사용할 provider까지 명시했다면 <code>terraform init</code>을 통해 사용할 인프라 공급자(여기서 AWS)의 API를 호출할 수 있는 플러그인을 가져오자.</p>
<p><code>.terraform/providers</code> 하위에 해당 provider의 플러그인 파일을 확인할 수 있을 것이다.</p>
<p>나의 경우 <code>terraform-provider-aws_v5.80.0_x5.exe</code> 파일이다</p>
<h2 id="vpc-모듈">VPC 모듈</h2>
<p>이제 모듈 구조를 적용해볼테니 프로젝트 최상위 경로에 <code>modules</code> 폴더를 만들고 그 아래에 <code>vpc</code> 폴더를 생성한다.</p>
<p>그리고 아래와 같이 <code>main.tf</code>를 작성한다.
<strong>modules/vpc/main.tf</strong></p>
<pre><code>resource &quot;aws_vpc&quot; &quot;vpc&quot; {
  cidr_block = var.vpc_cidr_block
  tags = {
    Name = &quot;vpc-${var.region}&quot;
  }

}</code></pre><p>resource 블록은 실제로 AWS 계정안에 생성할 리소스를 명시하는 블록이다.</p>
<p>resource 블록을 작성할 때에는 상단에 언급했던 문서를 꼭 참고해 필요한 인자가 무엇인지 파악하고 작성해야 한다.</p>
<p>우리는 VPC를 먼저 만들어야하기에 <code>resource &quot;aws_vpc&quot;</code> 블록을 작성한다.</p>
<p><code>resource &quot;aws_vpc&quot; &quot;vpc&quot;</code>는 AWS 프로바이더를 통해 생성할 VPC를 정의하는 의미이며, 뒤의 <code>&quot;vpc&quot;</code>가 해당 리소스의 개체명이 된다.</p>
<p>객체 지향 언어를 사용해봤다면 금방 이해가 갈 것이다.</p>
<p>그리고 안에 <code>cidr_block = var.vpc_cidr_block</code>으로 VPC에서 사용할 IPv4 범위를 정의한다. </p>
<p><code>var</code>은 variable 블록에서 선언한 변수를 가져오는 것이며, <code>variable.tf</code> 파일에 정의되어 있다.</p>
<p><code>tags = {
    Name = &quot;vpc-${var.region}&quot;
  }</code>는 VPC의 이름을 정하기 위해 태그를 달아준 것이며 자유롭게 변경해도 된다.</p>
<p><strong>modules/vpc/variable.tf</strong></p>
<pre><code>variable &quot;vpc_cidr_block&quot; {
  type = string
}
variable &quot;region&quot; {
  type = string
}</code></pre><p>variable 블록에서는 외부에서 resource 블록안으로 주입시켜줄 변수를 선언한다.</p>
<p>VPC의 CIDR과 리전을 꼭 외부에서 주입시켜야 하는가에 대한 질문이 생길 수 있는데 꼭 그러지 않아도 된다.</p>
<p>다만 환경별로 달라질 수 있는 변수를 주로 <code>terraform.tfvars</code> 파일에 담아 관리하기 때문에 그렇다.</p>
<p><strong>variable.tf</strong></p>
<pre><code>variable &quot;vpc_cidr_block&quot; {
  type = string
}
variable &quot;region&quot; {
  type    = string
}</code></pre><p>변수명만 같을 뿐 여기는 루트 모듈의 <code>variable.tf</code>이다.</p>
<p><strong>terraform.tfvars</strong></p>
<pre><code>vpc_cidr_block = &quot;10.100.0.0/16&quot;
region = &quot;ap-northeast-2&quot;</code></pre><p>VPC CIDR을 <code>10.100.0.0/16</code>으로 정했고 리전은 서울 리전이다.</p>
<p><code>terraform.tfvars</code> 파일에 명시되어 있지 않고, default 값이 정해져있지 않다면 <code>terraform apply</code> 단계에서 주입시킬 수도 있다.</p>
<p>참고로 <code>terraform.tfvars</code>은 로컬 환경에 필요한 변수를 정의하기 때문에 GIT에는 업로드하지 않는 것이 좋다.</p>
<p>권장하는 Terraform gitignore 파일 목록은 아래 링크에 있다.</p>
<ul>
<li><a href="https://github.com/github/gitignore/blob/main/Terraform.gitignore">https://github.com/github/gitignore/blob/main/Terraform.gitignore</a></li>
</ul>
<p>그리고 이제 다시 루트 모듈로 돌아가 <code>main.tf</code>에서 VPC 모듈을 호출한다.</p>
<p><strong>main.tf</strong></p>
<pre><code>terraform {
  required_providers {
    aws = {
      source  = &quot;hashicorp/aws&quot;
      version = &quot;5.80.0&quot;
    }
  }
}

provider &quot;aws&quot; {
  region = var.region
}

module &quot;vpc&quot; {
  source         = &quot;./vpc&quot;
  vpc_cidr_block = var.vpc_cidr_block
  region         = var.region
}</code></pre><p>루트 모듈에서 하위 모듈을 호출한 이후에는 <code>terraform init</code>을 꼭 해주어야 한다.</p>
<p>이렇게 되면 <code>terraform.tfvars</code> 파일에 명시된 <code>vpc_cidr_block</code>, <code>region</code> 값이 루트 모듈의 <code>variable.tf</code>에 명시된 변수에 담겨 <code>module &quot;vpc&quot;</code> 블록안에 <code>var.vpc_cidr_block</code>, <code>var.region</code>에 담겨 최종적으로 리소스 모듈에 주입되는 것이다.</p>
<p>이렇게 작성하고 루트 모듈 커맨드 창에서 <code>terraform plan</code>을 입력하면 Terraform 코드를 통해 수행될 계획이 출력된다.</p>
<p>그리고 <code>terraform apply</code> 후 실행 계획이 그대로 출력된다면 <code>yes</code>를 입력해 VPC를 생성하고 AWS 콘솔에 접속하여 생성된 VPC를 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/98afc8ec-225a-4cde-9f65-9fd398be5832/image.png" alt="vpc"></p>
<p>원하는 CIDR과 리전 값으로 생성이 잘 되었다.</p>
<p>이제 서브넷을 생성해보자.</p>
<h2 id="subnet-모듈">Subnet 모듈</h2>
<p>서브넷을 통해 모듈화를 통해 왜 재사용성이 증가하고 중복 코드가 줄어드는지 알아보자.</p>
<p>우선 서브넷에는 퍼블릭 서브넷과 프라이빗 서브넷이 존재한다.</p>
<p>퍼블릭 서브넷은 인터넷 접근이 자유롭고 외부 트래픽도 자유롭게 받을 수 있는 서브넷이며, 프라이빗 서브넷은 인터넷 접근이 제한적이며 내부 네트워크에서만 혹은 지정한 호스트만 접근 가능한 서브넷이다.</p>
<p>우리는 하나의 서브넷 모듈을 만들고 두 종류의 서브넷을 만들 것이다.</p>
<p><code>modules</code> 폴더 하위에 <code>subnet</code> 폴더를 만들어 <code>main.tf</code>를 작성한다.</p>
<p><strong>modules/subnet/main.tf</strong></p>
<pre><code>resource &quot;aws_subnet&quot; &quot;subnet&quot; {
  for_each = var.subnet_cidr_blocks
  vpc_id = var.vpc_id
  cidr_block = each.value
  availability_zone = each.key
  map_public_ip_on_launch = var.automatic_public_ip
  tags = {
    Name = &quot;subnet-${each.key}-${var.access_modifier}&quot;
  }
}</code></pre><p>처음보면 당황스러울 수 있으나 어렵지 않다.</p>
<p>우선 나는 외부에서 각 서브넷의 CIDR을 지정해 주입할 것이고, 생성할 서브넷 수만큼 정의할 것인데, 그 목록을 변수 <code>subnet_cidr_blocks</code>에 <code>&quot;key&quot; = &quot;value&quot;</code> 형태의 <code>map(string)</code> 형태로 정의할 것이다.</p>
<p>여기서 <code>for_each = var.subnet_cidr_blocks</code> 구문을 보면 <code>var.subnet_cidr_blocks</code> 담긴 항목만큼 반복해 리소스를 생성한다.</p>
<p>즉, <code>subnet_cidr_blocks</code>에 CIDR을 4개 정의했다면, 서브넷이 4개가 생기는 것이다.</p>
<p>아래 <code>each.value, each.key</code>는 <code>for_each</code>의 각 순회되는 키와 밸류에 해당하는 값을 가져오는 것이고 여기선 <code>each.key</code>가 AZ, <code>each.value</code>가 CIDR이 되겠다.</p>
<p><code>vpc_id, cidr_block, availability_zone</code>은 서브넷의 소속 VPC, CIDR, AZ를 지정하는 것이며, <code>map_public_ip_on_launch</code>은 해당 서브넷에 생성되는 인스턴스에 자동으로 공인 IP를 부여할지에 대한 여부이다.</p>
<p>여기서 <code>vpc_id</code>의 경우 위 VPC 모듈에서 생성한 VPC의 <code>id</code>를 가져와야 한다.</p>
<p>즉, VPC 모듈에서 서브넷 모듈로 <code>id</code>를 전달해주어야 한다는 의미이다.</p>
<p>모듈과 모듈간의 데이터 전달은 <code>output.tf</code>, <code>variable.tf</code>를 통해 가능하다.</p>
<p><strong>modules/vpc/output.tf</strong></p>
<pre><code>output &quot;vpc_id&quot; {
  value = aws_vpc.vpc.id
}</code></pre><p>VPC 모듈의 <code>output.tf</code> 파일에서는 외부로 전달시킬 값을 선언하고, 반대로 값을 받아야 하는 서브넷 모듈에서는 <code>variable.tf</code> 파일에 변수를 선언한다.</p>
<p><strong>modules/subnet/variable.tf</strong></p>
<pre><code>variable &quot;vpc_id&quot; {
  type = string
}</code></pre><p>데이터 전달은 두 모듈의 상위 모듈(여기서는 루트 모듈)에서 아래와 같이 수행할 수 있다.</p>
<p><strong>main.tf</strong></p>
<pre><code>module &quot;vpc&quot; {
  source         = &quot;./vpc&quot;
  ...
}

module &quot;public_subnet&quot; {
  source              = &quot;./subnet&quot;
  ...
  vpc_id              = module.vpc.vpc_id
  ...
}</code></pre><p>보통 퍼블릭 서브넷은 배치된 인스턴스마다 공인(퍼블릭) IP를 갖고 인터넷 게이트웨이로 직접 통신하고, 프라이빗 서브넷은 공인 IP 없이 NAT 게이트웨이(또는 인스턴스)를 통해 인터넷 게이트웨이를 통과해 외부와 통신한다.</p>
<pre><code>resource &quot;aws_internet_gateway&quot; &quot;internet_gateway&quot; {
  count = var.access_modifier == &quot;public&quot; ? 1 : 0
  vpc_id = var.vpc_id
  tags = {
    Name = &quot;igw-${var.region}&quot;
  }
}

resource &quot;aws_nat_gateway&quot; &quot;nat_gateway&quot; {
  count = var.access_modifier == &quot;private&quot; ? 1 : 0
  allocation_id = aws_eip.nat_ip[0].id
  subnet_id = var.nat_subnet_id
  tags = {
    Name = &quot;nat-${keys(var.subnet_cidr_blocks)[0]}-public&quot;
  }
}

resource &quot;aws_eip&quot; &quot;nat_ip&quot; {
  count = var.access_modifier == &quot;private&quot; ? 1 : 0
  domain = &quot;vpc&quot;
  tags = {
    Name = &quot;eip-${keys(var.subnet_cidr_blocks)[0]}-nat&quot;
  }
}</code></pre><p>나는 <code>access_modifier</code>라는 변수를 두어 내가 생성하려는 서브넷이 퍼블릭인지 프라이빗인지를 구분하였다.</p>
<p>변수 <code>access_modifier</code>가 <code>public</code>이면 퍼블릭 서브넷을 만드는 것이기 때문에, 인터넷 게이트웨이를 생성해야할 것이고, <code>private</code>이면 NAT 게이트웨이를 생성해야할 것이다.</p>
<p>그리고 NAT 게이트웨이는 고정 아이피가 필요하기 때문에, EIP(Elastic IP)를 통해 고정 아이피를 부여받기 위해 <code>aws_eip</code> 리소스를 생성하고 <code>allocation_id</code>에 <code>id</code>를 담아주고, NAT 게이트웨이가 배치될 서브넷까지 지정해준다.</p>
<p>참고로 공식 문서에서 NAT 게이트웨이를 AZ별로 하나씩 두는 것을 권장하는데, 찾아보니 속도가 빨라서라기보다는(물론 차이는 존재할 것 같으나 희미할 듯) 고가용성을 위해서라고 한다.</p>
<p>하지만 여기서는 하나만 생성한다.</p>
<p>그리고 <code>keys()</code> 함수로 <code>map</code>을 key의 리스트 형태로 만들어 가장 처음의 값을 가져온다.</p>
<p>여기서는 <code>ap-northeast-2a</code> 가 될 것이다.</p>
<p>그리고 라우트 테이블을 생성해 퍼블릭 서브넷은 외부 트래픽을 인터넷 게이트웨이로, 프라이빗 서브넷은 NAT 게이트웨이로 향하게 한다.</p>
<pre><code>resource &quot;aws_route_table&quot; &quot;route_table&quot; {
  vpc_id = var.vpc_id
  route {
    cidr_block = &quot;0.0.0.0/0&quot;
    gateway_id = var.access_modifier == &quot;public&quot; ? aws_internet_gateway.internet_gateway[0].id : null
    nat_gateway_id = var.access_modifier == &quot;private&quot; ? aws_nat_gateway.nat_gateway[0].id : null
  }
  route {
    cidr_block = var.vpc_cidr_block
    gateway_id = &quot;local&quot;
  }
  tags = {
    Name = &quot;rtb-${var.region}-${var.access_modifier}&quot;
  }
}

resource &quot;aws_route_table_association&quot; &quot;route_table_association&quot; {
  for_each = var.subnet_cidr_blocks
  subnet_id = aws_subnet.subnet[each.key].id
  route_table_id = aws_route_table.route_table.id
}</code></pre><p>첫 번째 <code>route</code> 블록에서는 모든 트래픽을 인터넷 게이트웨이(퍼블릭), NAT 게이트웨이(프라이빗)으로 보내게끔 설정하고 두 번째 <code>route</code> 블록에서는 도착지 IP가 VPC 내부인 경우 <code>local = (서브넷 내부)</code>로 이동하게끔 설정한다.</p>
<p>그리고 <code>resource &quot;aws_route_table_association&quot;</code>로 라우트 테이블과 서브넷을 연결시킨다.</p>
<p><strong>modules/subnet/variable.tf</strong></p>
<pre><code>variable &quot;vpc_id&quot; {
  type = string
}
variable &quot;subnet_cidr_blocks&quot; {
  type = map(string)
}
variable &quot;automatic_public_ip&quot; {
  type = bool
}
variable &quot;vpc_cidr_block&quot; {
  type = string
}
variable &quot;access_modifier&quot; {
  type = string
}
variable &quot;nat_subnet_id&quot; {
  type = string
  default = null
}
variable &quot;region&quot; {
  type = string
}</code></pre><p>루트 모듈에서 주입받을 변수들이고, NAT 게이트웨이 관련된 변수는 퍼블릭 서브넷일 경우 따로 주입하지 않기 위해 <code>default = null</code> 구문을 추가해주었다.</p>
<p>이제 루트 모듈에서 서브넷 모듈을 호출해보자.
<strong>main.tf</strong></p>
<pre><code>terraform {
  required_providers {
    aws = {
      source  = &quot;hashicorp/aws&quot;
      version = &quot;5.80.0&quot;
    }
  }
}

provider &quot;aws&quot; {
  region = var.region
}


locals {
  public_subnet_cidr_blocks = {
    &quot;ap-northeast-2a&quot; = cidrsubnet(var.vpc_cidr_block, 8, 0),
    &quot;ap-northeast-2b&quot; = cidrsubnet(var.vpc_cidr_block, 8, 1)
  }
  private_subnet_cidr_blocks = {
    &quot;ap-northeast-2a&quot; = cidrsubnet(var.vpc_cidr_block, 8, 10),
    &quot;ap-northeast-2b&quot; = cidrsubnet(var.vpc_cidr_block, 8, 11),
    &quot;ap-northeast-2c&quot; = cidrsubnet(var.vpc_cidr_block, 8, 12)
  }
}

module &quot;vpc&quot; {
  source         = &quot;./vpc&quot;
  vpc_cidr_block = var.vpc_cidr_block
  region         = var.region
}


module &quot;public_subnet&quot; {
  source              = &quot;./subnet&quot;
  subnet_cidr_blocks  = local.public_subnet_cidr_blocks
  vpc_id              = module.vpc.vpc_id
  automatic_public_ip = true
  vpc_cidr_block      = var.vpc_cidr_block
  access_modifier     = &quot;public&quot;
  region              = var.region
}

module &quot;private_subnet&quot; {
  source              = &quot;./subnet&quot;
  subnet_cidr_blocks  = local.private_subnet_cidr_blocks
  vpc_id              = module.vpc.vpc_id
  automatic_public_ip = false
  vpc_cidr_block      = var.vpc_cidr_block
  access_modifier     = &quot;private&quot;
  nat_subnet_id       = module.public_subnet.subnet_id[keys(local.public_subnet_cidr_blocks)[0]]
  region              = var.region
}</code></pre><p><code>locals</code> 블록이 추가되었는데, 로컬 변수를 선언하는 블록이다.</p>
<p>나는 각 서브넷의 <code>cidrsubnet()</code> 함수로 CIDR 범위(value)와, AZ(key)를 선언해주었다.</p>
<p>그리고 <code>module &quot;public_subnet&quot;</code>와 <code>module &quot;private_subnet&quot;</code>을 선언해준다.</p>
<p>서브넷 리소스에서 필요했던 값을 주입해주고, 프라이빗 서브넷은 NAT 관련된 값까지 주입해주었다.</p>
<p>여기서 <code>nat_subnet_id</code> 변수에는 NAT 게이트웨이가 배치될 퍼블릭 서브넷의 <code>id</code> 값이 필요하다.</p>
<p>때문에 먼저 생성되는 퍼블릭 서브넷 모듈에서 후에 생성될 프라이빗 서브넷 모듈로 데이터를 보내줘야 하는데 이 때 <code>output</code> 블록을 사용하면 된다.</p>
<p><strong>modules/subnet/output.tf</strong></p>
<pre><code>output &quot;subnet_id&quot; {
  value = [for subnet in aws_subnet.subnet : subnet.id]
}</code></pre><p>퍼블릭 서브넷 모듈에서 생성되는 서브넷의 <code>id</code> 값을 전부 <code>output</code>으로 내보내고, <code>nat_subnet_id = module.public_subnet.subnet_id[keys(local.public_subnet_cidr_blocks)[0]]</code> 구문으로 첫 번째 인덱스의 서브넷의 <code>id</code>를 주입해주게 된다.</p>
<p>이후 <code>terraform plan</code> → <code>terraform apply</code> → <code>yes</code> 까지 진행해주면 아래 사진과 같이 퍼블릭 서브넷 2, 프라이빗 서브넷 3, 인터넷 게이트웨이, NAT 게이트웨이, 라우트 테이블까지 생성된다.</p>
<p><strong>서브넷</strong>
<img src="https://velog.velcdn.com/images/dong-gwan/post/5c70b1f6-414a-4e4e-bbbf-04a8379d4366/image.png" alt=""></p>
<p><strong>인터넷 게이트웨이</strong>
<img src="https://velog.velcdn.com/images/dong-gwan/post/f668a03e-9e27-4c5b-b6f7-a54e84638ffd/image.png" alt=""></p>
<p><strong>NAT 게이트웨이</strong>
<img src="https://velog.velcdn.com/images/dong-gwan/post/23452485-fae1-4f8a-b3be-2f413a5a827f/image.png" alt=""></p>
<p><strong>라우트 테이블</strong>
<img src="https://velog.velcdn.com/images/dong-gwan/post/d3432b79-a553-404c-b9df-80ef836a3363/image.png" alt=""></p>
<p>그리고 VPC 리소스 맵을 통해 서브넷과 게이트웨이간 경로를 확인한 결과는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/53e9106e-44cc-49f6-9591-c0e108383d85/image.png" alt=""><img src="https://velog.velcdn.com/images/dong-gwan/post/2eefcb02-2b39-4b14-bd4c-81527aeac161/image.png" alt=""></p>
<p>퍼블릭 서브넷은 인터넷 게이트웨이로, 프라이빗 서브넷은 NAT 게이트웨이로 잘 연결되었다.</p>
<p>지금까지 완성된 설계도는 이렇다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/04c18cde-0bf1-4782-9f31-4b5cb7bd7056/image.png" alt=""></p>
<h2 id="마치며">마치며</h2>
<p>이처럼 서브넷 리소스를 모듈화해 하나의 서브넷 모듈로 두 종류의 서브넷을 생성해볼 수 있었다.</p>
<p>더 세분화시킬 점은 있지만, 이번 글에서는 결국 반복되는 <code>resource</code> 블록을 줄이고 필요한 설정들(NAT, IGW, Route TABLE)을 하나의 모듈로 만들어 추상화할 수 있었다는 점에 주목하면 좋을 듯 싶다.</p>
<p>물론 나는 resource 블록으로 직접 퍼블릭, 프라이빗 서브넷 추가하는게 더 좋은데요?라는 생각이 들면 그렇게 해도 된다.</p>
<p>남들이 자주 사용하는 구조라고 해도 결국 나에게 맞는 구조를 찾고 적용해야 하기에 항상 정답은 없고 장단점이 존재하는 선택의 차이라고 보면 좋다.
(단지, 남들이 자주 사용하는 구조가 나에게도 맞는 구조가 될 가능성이 높다는 점...)</p>
<p>다음 글에서는 배스천 호스트로 사용할 EC2 인스턴스까지 생성하고 EKS 클러스터를 만들어 파드까지 배포해볼 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Terraform 주요 커맨드]]></title>
            <link>https://velog.io/@dong-gwan/Terraform-%EC%A3%BC%EC%9A%94-%EC%BB%A4%EB%A7%A8%EB%93%9C</link>
            <guid>https://velog.io/@dong-gwan/Terraform-%EC%A3%BC%EC%9A%94-%EC%BB%A4%EB%A7%A8%EB%93%9C</guid>
            <pubDate>Wed, 23 Oct 2024 09:59:30 GMT</pubDate>
            <description><![CDATA[<h2 id="주요-커맨드">주요 커맨드</h2>
<h3 id="init">init</h3>
<pre><code class="language-hcl">terraform init</code></pre>
<ul>
<li>루트 모듈에서 실행하며 명시된 구성 파일에 필요한 프로바이더, 모듈 등을 구성하고 초기화</li>
<li>주요 옵션<ul>
<li>-upgrade : .terraform.lock.hcl에 명시된 버전 정보를 따르지 않고 별도로 명시한 버전으로 초기화</li>
</ul>
</li>
</ul>
<h3 id="plan">plan</h3>
<pre><code class="language-hcl">terraform plan</code></pre>
<ul>
<li>테라폼 구성 파일을 읽고 인프라의 어떤 변경 사항이 생기는지 실행 계획을 출력</li>
<li>주요 옵션<ul>
<li>-detailed-exitcode : 명령어의 결과를 exitcode로 출력함<ul>
<li>자동화 파이프라인에 사용</li>
<li>0 : 변경 사항 없는 성공</li>
<li>1 : 오류 사항 있음</li>
<li>2 : 변경 사항 있는 성공</li>
</ul>
</li>
<li>-out : 실행 계획을 파일로 생성</li>
<li>-destory : 테라폼이 관리하는 모든 개체 삭제 계획</li>
</ul>
</li>
</ul>
<h3 id="apply">apply</h3>
<pre><code class="language-hcl">terraform apply</code></pre>
<ul>
<li><code>terraform plan</code> 의 실행 계획을 실제로 수행함</li>
<li>주요 옵션<ul>
<li>-replace : 변경 사항이 없는 리소스를 강제로 재생성</li>
</ul>
</li>
</ul>
<h3 id="destory">destory</h3>
<pre><code class="language-hcl">terraform destory</code></pre>
<ul>
<li>테라폼에서 관리하는 모든 개체를 제거</li>
<li>주요 옵션<ul>
<li>-auto-approve : 승인 절차 없이 계획을 실행</li>
</ul>
</li>
</ul>
<h3 id="validate">validate</h3>
<pre><code class="language-hcl">terraform validate</code></pre>
<ul>
<li>작성한 테라폼 구성 파일에 문법적인 오류가 있는지 확인</li>
</ul>
<h3 id="fmt">fmt</h3>
<pre><code class="language-hcl">terraform fmt</code></pre>
<ul>
<li>작성한 테라폼 구성 파일에 표준 형식과 표준 스타일을 적용</li>
<li>서로 다른 개발자가 작성한 후 병합된 코드의 가독성 향상 목적</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Newman + Github Actions으로 API 테스트 자동화하고 Slack 알림 받기]]></title>
            <link>https://velog.io/@dong-gwan/Newman-Github-Actions%EC%9C%BC%EB%A1%9C-API-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%90%EB%8F%99%ED%99%94-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dong-gwan/Newman-Github-Actions%EC%9C%BC%EB%A1%9C-API-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%90%EB%8F%99%ED%99%94-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 31 Aug 2024 02:02:11 GMT</pubDate>
            <description><![CDATA[<h2 id="시작하며">시작하며</h2>
<p>이번 글에서는 필자가 회사에서 구성한 Newman API 테스트 프로세스에 대해 설명하려 한다.</p>
<h2 id="newman">Newman</h2>
<p>Newman이란 간단하게 Postman의 CLI 버전이다.</p>
<p>NPM을 통해 설치할 수 있고, 사실 Postman도 CLI가 따로 존재하기는 한다.</p>
<p>둘 다 포스트맨에서 개발하였으며, Newman은 오픈 소스로 공개해 커뮤니티 기여로 지원하고 있고 Postman-CLI는 포스트맨 자체에서 유지/보수한다고 한다.</p>
<p>둘의 차이는 <a href="https://learning.postman.com/docs/postman-cli/postman-cli-overview/">포스트맨 공식 문서</a>에 존재한다.</p>
<p>필자가 사용해봤을 때, 둘 다 유의미한 차이가 존재하지는 않고 Newman의 경우 테스트 결과를 HTML 리포트 형식으로 받을 수 있는 패키지와 Slack 알림 기능 패키지를 NPM을 통해 쉽게 받을 수 있어 Newman을 사용하기로 정하였다.</p>
<h2 id="설치">설치</h2>
<p>Newman의 설치 방법은 NPM이 설치된 상태에서 아래 명령어를 입력한다.</p>
<pre><code>npm install -g newman</code></pre><p>그리고 테스트 결과를 HTML 리포트로 생성하여 슬랙 알림으로 받을 수 있게끔 아래의 패키지도 같이 설치한다.</p>
<pre><code>npm install -g newman-reporter-htmlextra newman-reporter-slackmsg
</code></pre><h2 id="사용법">사용법</h2>
<p>Newman의 사용법은 아주 간단하다.</p>
<p>먼저 Postman으로 테스트 케이스를 작성하고, 테스트의 성공 조건을 작성해준다.</p>
<p>필자는 Response의 Code가 200(성공)이면, 해당 테스트 케이스는 성공이라고 작성하였으며, 자신이 원하는 다른 방식으로 작성해도 상관없다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/f9fb3cc5-723d-4f3d-a617-5ba906d383ad/image.png" alt=""></p>
<p>그리고 해당 콜렉션을 Json으로 Export 하거나 Share로 접근할 수 있는 URL를 만들어준다.</p>
<p>필자는 Postman API 키를 생성해 URL를 만드는 방식을 택했다.</p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/03cdc505-04b2-419b-99a3-25114bc8b3b6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dong-gwan/post/727f7cd7-79af-498a-980d-9174bbfb7007/image.png" alt=""></p>
<p>그리고 아래의 명령어로 실행하면 해당 콜렉션에 있는 요청들로 테스트가 진행된다.</p>
<pre><code>newman run &lt;COLLECTION_URL&gt;</code></pre><h2 id="github-actions">Github Actions</h2>
<p>이제 Newman 테스트를 Github Actions을 통해 자동화해보자.</p>
<p>워크플로우 파일을 아래와 같이 작성했다.</p>
<pre><code>name: API Test Script
on: workflow_dispatch
jobs: 
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository code
        uses: actions/checkout@v4

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          java-version: 17
          distribution: &quot;oracle&quot;

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v3

      - run: chmod +x ./gradlew

      - run: nohup sudo ./gradlew bootrun --args=&#39;--spring.profiles.active=test&#39;  &gt; ./log.txt &amp;

      - run: sleep 20s

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm install -g newman newman-reporter-htmlextra newman-reporter-slackmsg

      - run: newman run ${{ secrets.POSTMAN_API_TEST_URL }} -r htmlextra,slackmsg --reporter-htmlextra-export report.html --reporter-slackmsg-webhookurl ${{ secrets.SLACK_WEBHOOK_URL_TO_ALERT_CHANNEL }}</code></pre><p>필자의 회사에서는 애플리케이션을 Spring Boot로 개발하기 때문에 JAVA와 Gradle을 Setup 해준 뒤 빌드를 진행하고, 빌드가 성공하면 그 워크플로우 머신에서 서버를 실행시킨다. </p>
<p>그 후 Newman 패키지를 설치해 localhost로 테스트 케이스를 작성한 뒤 실행하면 끝이다.</p>
<p>참고로 중요 정보는 Secret으로 저장해 액션내에서는 ${{ secrets.<SECRET_NAME> }}으로 사용해야 한다.</p>
<h2 id="문제점">문제점</h2>
<p>여기에는 두 가지 문제점이 있다.</p>
<p>1) 이 워크플로우 내에서 Spring Boot 애플리케이션이 연결될 데이터베이스는 어디에 있는가?</p>
<p>2) 테스트가 종료된 이후 쌓인 데이터를 어떻게 클렌징해줄 것인가?</p>
<p>외부 데이터베이스를 연결하려면, 해당 데이터베이스 서버가 존재하는 네트워크에서 방화벽으로 Github Actions Runner의 IP를 허용해주어야 하지만 Github Actions의 Runner의 IP 대역은 찾아보니 방화벽에 추가하는 것이 말이 안될 정도로 많다.</p>
<p>그렇다고 방화벽을 아예 오픈하는 것은 더더욱 말이 안될 것이다.</p>
<p>또 GET 요청만 테스트하는 경우에는 상관이 없을 수도 있는데 PUT,POST 등 데이터베이스에 데이터의 삽입과 변경이 행해지는 테스트가 종료된 이후에는 다시 원래 상태의 데이터베이스로 어떻게 돌릴지가 문제다.</p>
<p>필자는 위 두 문제를 Github Actions의 Service 컨테이너로 해결해보았다.</p>
<h2 id="서비스-컨테이너">서비스 컨테이너</h2>
<p>Github Actions에는 워크플로우 내에서 통신할 수 있는 서비스 컨테이너라는 것을 만들 수 있다.</p>
<p>이 서비스 컨테이너는 해당 Job이 처음 생성될 때 같이 생성되고 Job이 종료될 때 같이 종료된다.</p>
<p>즉, 테스트에 필요한 데이터를 미리 삽입해놓은 데이터베이스 컨테이너 이미지를 생성해두고 이를 워크플로우 내에서 실행시킨 뒤, 테스트가 끝나면 종료되기 때문에 클렌징이 필요하지 않다.</p>
<p>또, 방화벽을 오픈할 필요없이 동일한 워크플로우 머신 내에서 컨테이너가 실행되는 것이기 때문에
위 두 문제를 해결할 수 있다.</p>
<p>Dockerfile로 테스트에 필요한 초기 데이터를 갖고 있는 데이터베이스 컨테이너 이미지를 생성한다.(이는 테스트를 위해 필요한 초기 데이터가 없다면 필요없는 과정이다.)</p>
<pre><code>FROM postgres:15.6

추후 추가 예정..</code></pre><p>그리고 컨테이너 이미지를 만들어 이미지 저장소에 푸시한다.</p>
<pre><code>docker build -t &lt;CONTAINER_IMAGE_REGISTRY&gt;/REPO:TAG .
docker push &lt;CONTAINER_IMAGE_REGISTRY&gt;/REPO:TAG</code></pre><p>이 후 워크플로우 파일로 돌아가 해당 컨테이너를 실행하게끔 설정해준다.</p>
<pre><code>name: API Test Script
on: workflow_dispatch
jobs: 
-- 이 부분 추가
    services:
      postgres:
        image: &lt;CONTAINER_IMAGE_REGISTRY&gt;/REPO:TAG
        credentials:
          username: _json_key
          password: ${{ secrets.GOOGLE_ARTIFACT_REGISTRY_CREDENTIALS }}
        ports:
          - 5432:5432
        env:
          POSTGRES_PASSWORD: postgres
--
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository code
        uses: actions/checkout@v4

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          java-version: 17
          distribution: &quot;oracle&quot;

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v3

      - run: chmod +x ./gradlew

      - run: nohup sudo ./gradlew bootrun --args=&#39;--spring.profiles.active=test&#39;  &gt; ./log.txt &amp;

      - run: sleep 20s

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm install -g newman newman-reporter-htmlextra newman-reporter-slackmsg

      - run: newman run ${{ secrets.POSTMAN_API_TEST_URL }} -r htmlextra,slackmsg --reporter-htmlextra-export report.html --reporter-slackmsg-webhookurl ${{ secrets.SLACK_WEBHOOK_URL_TO_ALERT_CHANNEL }}

      - uses: MeilCli/slack-upload-file@v4
        with:
          slack_token: ${{ secrets.SLACK_TOKEN_NEWMAN }}
          channel_id: ${{ secrets.SLACK_ALERT_CHANNEL_ID }}
          file_path: &#39;report.html&#39;
        if: always()

      - uses: MeilCli/slack-upload-file@v4
        with:
          slack_token: ${{ secrets.SLACK_TOKEN_NEWMAN }}
          channel_id: ${{ secrets.SLACK_ALERT_CHANNEL_ID }}
          file_path: &#39;log.txt&#39;
        if: failure()</code></pre><p>필자는 Google Cloud Platform에 있는 컨테이너 레지스트리 Artifact Registry에 저장해 사용하는 형태라 관련 인증정보가 필요해 추가된 상태이다.</p>
<p>그리고 Spring Boot에서 Test Profile을 만들어 <code>localhost:5432</code>로 연결하게끔 실행해주면 Newman 테스트를 성공할 수 있다.</p>
<p>테스트가 종료되면 결과가 슬랙 채널로 올라오게 되고, 필자는 HTML 리포트 파일을 슬랙 채널에 업로드하는 액션을 추가로 사용하여 아래와 같이 알림을 주게 된다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/fc2be824-8b18-4b87-82df-d6a8b241c98a/image.png" alt=""></p>
<p>그리고 실패 시에는 애플리케이션 로그 파일을 함께 업로드한다.
<img src="https://velog.velcdn.com/images/dong-gwan/post/5530977f-bdb4-4278-8ec4-314e6cc2e602/image.png" alt=""></p>
<h2 id="마치며">마치며</h2>
<p>오늘은 필자가 회사에서 구성한 테스트 자동화 프로세스를 구성했던 방법을 설명해보았다.</p>
<p>슬랙 웹 훅 등 아직 글을 전부 작성한 것은 아니라 보완해야 할 점은 추가로 보완해나갈 것이다.</p>
]]></description>
        </item>
    </channel>
</rss>