helm을 배워보자 6일차 - Templates

0

helm

목록 보기
6/8

Developing Templates

helm의 최대 강적이자, 때어낼 수 없는 template문법이다. 하나하나 알고보면 별게 아니지만, 모아놓고 복잡하게 쓰면 정말 답없이 쓸 수 있기 때문에, 최대한 basic한 내용만 가져다 쓰기로 하자.

Template 문법

이전에도 말했지만 helm은 golang으로 만들어졌기 때문에 template문법도 golang이다. 다행이도 golang의 template문법은 다른 언어들보다는 괜찮다는 평가를 받는다. 그러나, 애시당초 template문법 자체가 너무 피로도가 높기 때문에 최대한 simple하게 쓰는 것을 추천한다.

Actions

template 문법을 시작할 때는 항상 {{ 으로 열어주고 }}으로 닫아야 한다. 이것을 actions라고 한다.

actions 안에 앞 뒤에 -을 넣어줄 수 있는데, -는 value의 맨 앞 또는 맨 뒤의 white space를 지우겠다는 것이다.

{{ "Hello" -}} , {{- "World" }}

다음의 결과는 "Hello,World"가 나온다. 이는 -`을 사용하여 앞 뒤의 white space를 지웠기 때문이다.

한 가지 조심해야할 점은 -와 나머지 action 사이에 ASCII whitespace가 있어야 한다는 것이다. 만약 숫자의 경우 문제가 된다. {{-12}} 다음의 값은 -12가 나온다는 것이다.

helm이 template로 전달하는 정보

.Values를 통해서 전달된 value를 받아 template를 렌더링 할 수 있다는 사실을 알 수 있었다.

이외에 release에 대한 정보를 넘겨주기도 하는데, 정해진 틀이 있다. 먼저 .Release로 시작해야하는데, 다음과 같다.

  1. .Release.Name: release의 이름
  2. .Release.Namespace: chart가 release된 namespace
  3. .Release.IsInstall: release workload가 설치되면 true로 나온다.
  4. .Release.IsUpgrade: release가 upgrade되거나 rollback되면 true로 나온다.
  5. .Release.Service: release를 수행하는 service들에 대한 list가 나온다. helm install을 통해 설치할 때 Helm으로 설정된다.

Chart.yaml file에 있는 chart metadata도 template에 전달이 가능하다.

  1. .Chart.Name: chart의 이름
  2. .Chart.Version: chart의 version
  3. .Chart.AppVersion: application version
  4. .Chart.Annotations: key-value annotation list

재밌는 건 Chart.yaml 파일에 가보면 소문자로 시작하는 이름들이 여기서는 대문자로 시작한다. 이건 golang을 해본 사람이라면 쉽게 이해할 수 있는데, golang에서 public 접근 지시자는 대문자로 시작하기 때문이다. 더불어 Chart.yaml에 custom metadata를 넣어서 .Chart로 접근하는 방법은 쓰지말도록 하자. 적용되지 않을 것이다. 차라리 annotation에 추가하는 것이 좋다.

kubernetes version에 따라서 helm의 동작을 바꿔야 하는 경우들도 있다. 가령, kubernetes version에 따라 지원하는 manifest 정의가 다를 수 있다. 이러한 데이터를 helm이 kubernetes를 통해 받아와서 template에 전달해줄 수 있다. 이 경우 .Capabilities를 사용한다.

  1. .Capabilities.APIVersions: cluster에서 사용 가능하 API version과 resource type을 전달한다.
  2. .Capabilities.KubeVersion.Version: kubernetes version이다.
  3. .Capabilities.KubeVersion.Minor: kubenetes version의 minor 값을 가져온다. 어차피 major는 1에서 바뀐 적이 없기 때문에 minor가 중요하다.

.Capabilities을 사용할 때 helm template를 실행하면 helm install이나 helm package처럼 직접 kubernete cluster에 정보들을 묻지 않는다. 단지, helm을 통하여 이전에 이미 알고있던 kubernetes cluster 정보를 바탕으로 template를 렌더링 해줄 뿐이다. 이는 helm template가 kubernetes cluster와 상호작용하지 않고 template 작업을 하기 때문에 이렇게 구현된 것이다.

마지막으로 template 자체에 대한 정보는 .Template를 통해서 얻을 수 있다.

  1. .Template.Name: template 파일의 path와 이름이 나온다. anvil/templates/deployment.yaml.
  2. .Template.BasePath: 현재 chart의 template 디렉터리 path가 나온다. anvil/templates

Pipelines

pipeline은 command의 연속으로 하나의 체인을 이룬 거라고 생가하면 된다. linux pipeline와 같이 이전 command의 output이 다음 command의 입력으로 들어가서 command가 연속으로 실행된다.

character: {{ .Values.character | default "Sylvester" | quote }}

linux pipeline과 마찬가지로 |로 표현한다. 첫번째 .Values.charactercharacter값을 가져오고, 이 결과가 두 번째 연산자인 default 함수에 들어가는 것이다. default 함수는 입력받은 값이 비어있으면 default값을 반환해주는데, 여기서는 Sylvester이다. 다음으로 default함수를 거친 결과가 quote함수의 입력으로 들어가는 것이다. quote함수는 입력으로 들어간 문자열에 quote를 씌워준다.

가령 다음의 id값이 있다고 하자.

id: 12345e2

겉보기에는 문자열 같지만 yaml은 문자와 숫자를 구분하기 때문에 숫자이다. 때문에 문자열이라는 표시를 해주기 위해서 quote를 사용하여 표현해주어야 한다.

id: "12345e2"

quote로 wrapping되면서 yaml parser는 이 id값을 string값으로 생각할 것이다.

Template Functions

helm에서 기본적으로 제공해주는 template function들이 있는데, 자주 쓰이는 것들이 있다. 가령 indentnindent 같은 것들이 있다.

다음의 예제를 보도록 하자.

      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}

.Values.podSecurityContext은 하나의 data인데, yaml file로 받기 위해서 toYaml을 통해 yaml 파일로 만든 것이다. 잘보면 action 맨 앞에 -가 있는 것을 볼 수 있다. 따라서 output의 맨 앞 쪽 whitespace가 모두 지워질 것이라는 것을 예상할 수 있다. 심지어 securityContext: 앞까지 지워질텐데, 이렇게 만들면 securityContext 계위에 안맞기 때문에 indent가 필요하다. nindent는 new line을 하나만들고 입력한 숫자만큼 indent를 실행한다. 이렇게 하면 계위가 맞게되는 것이다.

indentnindent와 달리 new line을 만들지 않지만 indent를 하는 것은 똑같다.

toYaml말고도 toJson, toToml도 있다. toYaml은 kubernetes manifest를 만들 때 사용하는 것이고, toJson은 주로 configmap과 같은 설정값 데이터를 넣을 때 쓴다.

참고로 pipeline으로 앞 command의 output이 뒤 command의 input에 들어갈 때는 맨 뒤의 argument로 들어가는 것이다. 가령 toYaml의 결과가 nindent의 맨 뒤 argument에 들어가는 것이다.

https://helm.sh/docs/chart_template_guide/function_list/

여기 여러 template 함수들이 있다.

Methods

앞서 보았던 .Capabilities.APIVersions에도 method가 있는데 .Capabilities.APIVersions.Has가 method이다. 이는 argument로 kubernetes API나 type을 받아 cluster가 해당 kubernetes API나 type을 호환할 수 있는 지 결과를 true, or false로 내보낸다. 가령 batch/v1과 같은 group, version을 check받을 수 있다.

file을 다루는 .Files 역시도 여러 method들을 가지고 있다. 다음을 보도록 하자.

  1. .Files.Get name: path를 포함한 file name을 받아서 file의 content를 string으로 가져와 준다.
  2. .Files.GetBytes: .Files.Get과 유사하지만 string이 아니라 bytes 배열로 가져온다. 사실 golang 개발자 입장에서 이게 더 익숙하다.
  3. .Files.Glob: glob pattern을 받아 매칭된 file정보를 담은 Files object를 반환한다.
  4. .Files.AsConfig: file group을 입력으로 받아서 flatten된 YAML파일을 반환한다. 이 yaml file은 kubernetes configmap manifest의 data section에 딱 알맞게 나온다. 이는 .Files.Glob과 함께 사용할 때 좋다.
  5. .Files.AsSecrets: .Files.AsConfig와 비슷하게 flatten된 yaml을 반환하되, secret의 data section에 딱 맞게 전달해준다. 즉, base64 인코딩이 되어있다.
  6. Files.Lines: filename을 입력으로 주면 newline으로 분리된 file의 content 배열을 전달해준다.

다음의 예시는 config directory안에 file들을 모두 읽어서 secret으로 전달해주는 것이다.

apiVersion: v1
kind: Secret
metadata:
  name: {{ include "example.fullname" . }}
type: Opaque
data:
{{ (.Files.Glob "config/*").AsSecrets | indent 2 }}

결과로 다음과 같이 나온다.

apiVersion: v1
kind: Secret
metadata:
  name: myapp
type: Opaque
data:
  jetpack.ini: ZW5hYmxlZCA9IHRydWU=
  rocket.yaml: ZW5hYmxlZDogdHJ1ZQ==

file자체가 secret안에 data section으로 들어가는 것이다.

chart안에서 kubernetes resource 묻기

helm은 kubernetes cluster내의 resource들을 볼 수 있도록 하는 template function을 포함한다. lookup template function은 개별 object 또는 list object를 반환할 수 있다. 만약 command가 cluster와 상호작용할 수 없는 상태에 실행되면 빈 응답이 전달된다.

다음의 예제는 anvil namespace의 Deploymentrunner를 loop up하고 runner deployment의 metadata.annotations를 가져오는 것이다.

{{ (lookup "apps/v1" "Deployment" "anvil" "runner").metadata.annotations }}

lookup 함수의 인자는 다음과 같다.

  1. API version: kubernetes에 설치된 object의 API version을 입력하면 된다. 보통 v1, apps/v1이 있다.
  2. object 종류: resource type으로 여기서는 Deployment이다.
  3. namespace: resource가 있는 namespace로 여기는 anvil이다.
  4. resource의 name: 특정 object의 이름을 지정하 수 있고, 만약 비워두면 list로 받을 수 있다.

만약 결과가 list로 나온다면 .items를 통해서 loop가 가능하다.

{{ (lookup "v1" "ConfigMap" "anvil" "").items }}

한 가지 조심해야할 것은 lookup을 사용할 때, helm template와 실제 배포 결과가 다를 수 있다는 것이다. 이는 kubernetes api server와 상호작용을 해야하기 때문에 발생한 문제이다.

if/else/with

`if-else 문법은 기존의 programming 문법과 별반 다를 바 없다.

가령, Ingess 기능을 on/off 한다고 할 때 다음과 같이 할 수 있다.

ingress:
  enabled: false

해당 value를 이용하여 Ingress.yaml file에 기능을 on/off할 수 있다.

{{- if .Values.ingress.enabled -}}
...
{{- end }}

if만에 있는 조건문이 true라면 안에 있는 template문법들이 평가되어 렌더링된다. 재밌는 것은 shell을 쓰듯이 {{- end -}}가 있다는 것이다.

if문은 else와 함께 쓰여 실행될 수 있다.

{{- if .Values.ingress.enabled -}}
...
{{- else -}}
# Ingress not enabled
{{- end }}

그런데 andor조건을 사용하는 것이 조금 재밌는데, 다음과 같이 사용해야한다.

{{- if and .Values.characters .Values.products -}}
...
{{- end }}

and는 두 item 앞에 나와 사용되어야 한다. 이유는 간단한데, and라는 함수를 실행하는 것이기 때문에 argument로 두 식을 받는 것이다. 그래서 and가 앞에 있는 것이다. 이러한 이유로 or도 마찬가지의 로직을 가진다.

{{- if or (eq .Values.character "Wile E. Coyote") .Values.products -}}
...
{{- end }}

or에 넘기기 전에 eq 명령어로 true or fasle 검사를 하고 전달하는 것을 볼 수 있다. 참고로 eq는 eqaulity check로 두 argument가 동일한지 확인해준다. 이렇게 먼저 실행되어야할 부분에 대해서 ()으로 감싸주는 작업이 필요하다.

withif와 유사한데, if는 안에 조건문이 있었다면, with은 안에 특정 값이 비어있는 것인지 아닌지 확인하도록 하고, 비어있지 않다면 해당 값을 .에 가져와 사용할 수 있도록 한다.

{{- with .Values.ingress.annotations }}
annotations:
    {{- toYaml . | nindent 4 }}
{{- end }}

다음의 예제를 보면 with을 통해서 .Values.ingress.annotations값이 empty인지 아닌지 확인하고, 비어있지 않다면 해당 값을 .에 넣어준다. 따라서 annotations:안에 있는 ..Values.ingress.annotations가 된다.

with역시도 else와 함께 사용이 가능하다.

Variables

변수도 선언할 수 있는데 $을 앞에 붙여주면 된다. 단, 한 번 선언된 변수는 타입을 절대 바꿀 수 없다는 점을 알도록 하자.

변수를 만들 때 special syntax로 :=을 사용하여 선언과 동시에 초기화를 해줄 수 있다. 타입은 자동으로 들어간다. golang을 했던 사람이라면 쉽게 이해할 수 있다.

{{ $var := .Values.character }}

var이라는 변수를 선언하였고 .Values.character값이 초기화되었다. 이제 해당 변수를 어디에서든 사용할 수 있다.

character: {{ $var | default "Sylvester" | quote }}

$var이 전달되어 default 함수로 전달된다. 이때 $var.Values.character로 전달된 것이다.

만약 변수에 할당된 값을 바꾸고 싶다면 :=이 아니라 =을 사용하면 된다. 앞에서도 말했지만 :=은 변수 선언과 초기화를 동시에 할 때 사용하는 것이지, 할당을 할 때는 사용하는 것이 아니다.

{{ $var := .Values.character }}
{{ $var = "Tweety" }}

$var변수가 "Tweety"로 바뀌게 된다.

참고로 변수의 생명주기는 template가 실행되는 동안으로, 같은 action에서는 무조건 사용이 가능하며 template 내에 다른 곳에서도 사용이 가능하다.

Loops

for loop 대신에 range loop를 사용하여 list와 dict를 순회할 수 있다. dict는 참고로 golang에서 map과 같다.

다음의 yaml dict와 list를 보도록 하자.

# An example list in YAML
characters:
  - Sylvester
  - Tweety
  - Road Runner
  - Wile E. Coyote

# An example map in YAML
products:
  anvil: They ring like a bell
  grease: 50% slippery
  boomerang: Guaranteed to return

range 함수는 순회하는 방식이 두 가지있다. 아래는 element를 받아내는 방법으로 characters처럼 list의 경우 range로 list를 받아내고 .가 각 iteration item이 된다.

characters:
{{- range .Values.characters }}
  - {{ . | quote }}
{{- end }}

rangeend가 같이 쓰이는 것을 볼 수 있다. 그리고 각 item들은 .으로 들어오는 것을 알 수 있다.

결과를 확인하면 다음과 같다.

characters:
  - "Sylvester"
  - "Tweety"
  - "Road Runner"
  - "Wile E. Coyote"

다음은 key와 value를 받아내는 방법이다. dict에 매우 유용한데, list에도 사용이 가능하다. list의 경우는 index와 value가 된다.

products:
{{- range $key, $value := .Values.products }}
  - {{ $key }}: {{ $value | quote }}
{{- end }}

Named Templates

kubernetes manifest template안에 우리가 정의한 template를 중첩되게 넣고 싶을 때가 있을 것이다. 가령, label의 경우 application pod의 metadata로도 들어가지만, selector에도 들어가기 때문에 이를 중복으로 쓰는 것보다, 어디에 정의를 해놓고 불러내는 것이 더 좋다.

다음은 anvilselector label을 template로 만든 것이다.

{{/*
Selector labels
*/}}
{{- define "anvil.selectorLabels" -}}
app.kubernetes.io/name: {{ include "anvil.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}} 
  1. Selector labels: template function을 정의하기 이전에 comment로 주석을 써주는 것이다.
  2. {{- define "anvil.selectorLabels" -}}: define 문을 통해서 template function을 만들어준다. 이름은 anvi.selectorLabels이다.

define부터 end까지 안의 내용이 바로 template function의 code들이 된다고 생각하면 된다.

잘보면 {{- define ... -}}{{- end -}}에는 -가 앞뒤로 있는 것을 볼 수 있다. 이는 공백을 삭제하여 추가적인 line이 없도록 한 것으로 볼 수 있다.

이제 해당 template function을 사용해보도록 하자. 다음은 anvil chart의 Deployment 부분이다.

spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "anvil.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "anvil.selectorLabels" . | nindent 8 }}

include를 통해서 helm template function을 호출하면 된다. 재밌는 것은 whitespace를 모두 제거했었기 때문에 nindent를 통해서 indentation을 해주어야한다.

include는 두 가지 인자로 호출되는 것을 볼 수 있다. 첫번째는 실행하려는 template이고, 두 번재는 template에 전달하려는 argument이다. 여기서는 .을 전달하여 전체 global object를 전달하였다.

이제, anvil.selectorLabels template function을 다른 helm template function에도 중첩되게 사용해보도록 하자. 다음은 anvil.labels로 anvil의 label을 정의하는 template function이다.

{{/*
Common labels
*/}}
{{- define "anvil.labels" -}}
helm.sh/chart: {{ include "anvil.chart" . }}
{{ include "anvil.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}

내부적으로 anvil.selectorLabels이 사용되고, if문을 통해 몇 가지 label들이 추가되는 것을 볼 수 있다. 이처럼 다른 helm template function에 중첩되게 helm template function을 넣을 수 있다는 것을 알 수 있다.

helm template function을 사용하여 복잡한 logic을 단순화하여 제공할 수 있는데, 다음을 보도록 하자.

{{- define "anvil.getImage" -}}
{{- if .Values.image.digest -}}
{{ .Values.image.repository }}@{{ .Values.image.digest }}
{{- else -}}
{{ .Values.image.repository }}:
{{- .Values.image.tag | default .Chart.AppVersion }}
{{- end -}}
{{- end -}}

위는 anvil.getImage라는 helm template function으로 digest가 있을 때와 없을 때의 image이름을 결정해주는 함수이다. 이렇게 복잡한 image 생성 로직을 helm template function으로 감싸주어 만들면, 좀 더 편하게 사용할 수 있다.

image: "{{ include "anvil.getImage" . }}"

다음과 같이 image 처리 로직을 간단하게 만들어 줄 수 있다. 이는 마치 software program을 만드는 것과 같다.

이처럼 helm template function은 복잡한 logic을 분해하여 단순화하거나, 공유하여 사용해 여러 곳에서 같은 code에 대한 redundancy를 없애는 기능을 한다.

Debugging Templates

helm template를 개발하는 일은 쉽지 않은 일이다. 따라서, 이를 위한 debugging이 필요한데, helm에서는 3개의 debug기능을 제공한다.

Dry run

install, upgrade, roll back, uninstall에 관한 모든 helm chart command들은 dry run 명령어를 가지고 있다. 이는 실제 process를 실행하진 않지만, 해당 command의 결과가 무엇으로 나올 지 simulation하는 것이라고 생각하면 된다. 이는 모두 --dry-run flag로 실행된다. 가령 install 명령어에서 --dry-run을 실행하고 싶다면 helm install myanvil anvil --dry-run을 쓰면 된다. --dry-run을 쓰면 helm install명령어의 결과를 kubernetes api-server에 보내는 것이 아니라, stdout으로 보낸다. 이를 통해 잘 만들어진 것인지 아닌지 확인할 수 있는 것이다.

NAME: myanvil
LAST DEPLOYED: Mon Jul  8 13:12:48 2024
NAMESPACE: default
STATUS: pending-install
REVISION: 1
HOOKS:
---
...
MANIFEST:
...
NOTES:
1. Get the application URL by running these commands:
  export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=anvil,app.kubernetes.io/instance=myanvil" -o jsonpath="{.items[0].metadata.name}")
  export CONTAINER_PORT=$(kubectl get pod --namespace default $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
  echo "Visit http://127.0.0.1:8080 to use your application"
  kubectl --namespace default port-forward $POD_NAME 8080:$CONTAINER_PORT

HOOKSMANIFEST 부분이 바로 kubernetes에 전달되는 yaml파일이다. 너무 길기 때문에 위에서는 생략했다.

만약, template에서 에러가 존재하면 결과가 조금 다르게 나온다. 가령 anvil chart의 deployment.yaml file에 첫부분 }을 지워버리면 parsing error가 발생하게 된다. 이러한 부분을 --dry-run을 통해서 캐치할 수 있는 것이다.

Error: parse error at (anvil/templates/deployment.yaml:4): unexpected "}" in
operand

위 error msg만 보고도 어디에서 어떻게 문제가 발생했는 지 알 수 있다.

helm은 또한 문법 check까지도 해주는데, deployment.yaml file에 apiVersion을 삭제해보도록 하자. --dry-run을 실행하면 다음과 같은 에러가 발생한다.

Error: YAML parse error on anvil/templates/deployment.yaml: error converting
YAML to JSON: yaml: line 2: mapping values are not allowed in this context

error msg를 보면 yaml to json이라는 문구가 있는데, 이는 helm과 kubernetes에서 사용하는 yaml 파싱 라이브러리에서 yaml을 json으로 convert하여 사용하기 때문이다. error문구를 보면 두번째 라인에 잘못된 syntax가 사용하여 error가 발생했다는 것을 알 수 있다.

설치된 manifests 확인하기

--dry-run이 실제 install된 manifest와 다른 결과를 낼 수 있다고 하였다. 이는 --dry-run을 사용하면 kubernetes cluster에 요청이 가지 않기 때문이다. 따라서, 실제로 helm chart를 배포해보도록 하고, 그 결과가 내가 예상한 결과와 동일한 지 확인하는 process가 필요하다.

가령 istio와 같은 service mesh를 사용한다고 하자. 이 경우 pod에 sidecar로 service mesh container를 주입해주는데, --dry-run으로는 확인이 불가능하다. 왜냐면 kubernetes와 상호작용하여 istio가 런타임에 sidecar container를 주입해주기 때문이다. 설치된 manifest를 확인하는 방법 밖에 없는 것이다.

helm get manifest 명령어를 사용하면 현재 설치된 helm chart의 manifest를 보여준다.

helm get manifest  myanvil

다음의 결과를 볼 수 있다.

---
# Source: anvil/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: myanvil-anvil
  labels:
    helm.sh/chart: anvil-0.1.0
    app.kubernetes.io/name: anvil
    app.kubernetes.io/instance: myanvil
    app.kubernetes.io/version: "9.17.49"
    app.kubernetes.io/managed-by: Helm
---
# Source: anvil/templates/service.yaml
apiVersion: v1
kind: Service
...

이 처럼 실제 배포된 helm chart의 manifest를 보고 싶다면 helm get manifest를 사용하면 된다.

Linting Charts

chart를 만드는 중에 발생하는 문제점 중 하나는 lint이다. 가령, 문자로 대문자가 허용되지 않은 경우가 있거나, 들여쓰기가 실패한 경우들이 존재하는데, 이러한 문제점들은 helm을 통해서 쉽사리 알아차리기 쉽지 않다.

다음의 경우를 보면, 마지막 문자에 Wile이라는 글이 있는 것을 볼 수 있다. 이렇게 대문자가 있는 경우 error가 발생할 수 있는데, helm 자체에서는 이를 catch하기 쉽지 않다. 왜냐하면 template 자체의 error는 아니기 때문이다. 단지 kubernetes 자체 spec에서의 문제인 것이다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "anvil.fullname" . }}-Wile

helm lint를 사용하면 이러한 문제를 조기에 catch할 수 있다.

helm lint anvil
==> Linting anvil
[ERROR] templates/deployment.yaml: object name does not conform to Kubernetes
naming requirements: "test-release-anvil-Wile"

Error: 1 chart(s) linted, 1 chart(s) failed

helm lint를 통해서 어디서 문법 문제가 발생했는 지 catch할 수 있는 것이다.

0개의 댓글