Kubeflow – Katib : Metrics Collector

Metrics Collector 알아보기

앞서 하이퍼 파라미터 튜닝에서 사용했던 메트릭 수집기는 기본 수집기인 StdOut 메트릭 수집기였습니다. 이번에는 StdOud 메트릭 수집기에 필터를 적용하는 방법과 TensorFlowEvent, File 그리고 Custom 메트릭 수집기에 대해서 알아보겠습니다.

StdOud 메트릭 수집기에 필터 적용하기

StdOut 메트릭 수집기에 필터를 적용하는 방법에 대해서 알아보겠습니다. 기존 예제에서는 StdOut 으로 출력되는 메트릭을 수집하기 위해서 {{MetricsName}}={{MetricsValue}} 형태로 출력을 하였습니다. 필터를 사용하면 메트릭을 나타내는 형식을 지정할 수 있기 때문에, 모델 학습시 출력되는 기본적인 로그를 그대로 사용할 수 있습니다.

예를 든다면, mnist-simple.py 를 실행하면 다음과 같은 로그가 출력됩니다.

Epoch 1/5
50000/50000 [==============================] - 2s 46us/sample - loss: 0.3268 - accuracy: 0.9055 - val_loss: 0.1509 - val_accuracy: 0.9574
Epoch 2/5
50000/50000 [==============================] - 2s 42us/sample - loss: 0.1581 - accuracy: 0.9534 - val_loss: 0.1115 - val_accuracy: 0.9684
Epoch 3/5
50000/50000 [==============================] - 2s 40us/sample - loss: 0.1166 - accuracy: 0.9642 - val_loss: 0.1017 - val_accuracy: 0.9708
Epoch 4/5
50000/50000 [==============================] - 2s 40us/sample - loss: 0.0959 - accuracy: 0.9707 - val_loss: 0.0836 - val_accuracy: 0.9756
Epoch 5/5
50000/50000 [==============================] - 2s 42us/sample - loss: 0.0808 - accuracy: 0.9747 - val_loss: 0.0774 - val_accuracy: 0.9773

로그를 보면, 메트릭이 “accuracy: 0.9055 “, “val_accuracy: 0.9574” 이런 형식으로 출력되는 것을 확인 할 수 있습니다. 필터에 {{MetricsName}}:{{MetricsValue}} 형식을 추가해서 기본 로그에서 메트릭을 추출하도록 하겠습니다. 형식은 go 언어의 정규표현식을 사용할 수 있습니다.

다음은 {{MetricsName}}:{{MetricsValue}} 형식을 필터로 사용하는 metricsCollectorSpec 입니다.

metricsCollectorSpec:
    collector:
      kind: StdOut
    source:
      filter:
        metricsFormat:
          - "([\\\\w|-]+)\\\\s*:\\\\s*((-?\\\\d+)(\\\\.\\\\d+)?)"

모델 코드 만들기

텐서플로우 케라스로 작성한 mnist 숫자를 판별하는 모델입니다. Katib를 위한 별도의 로그는 출력하지 않습니다.

mnist-simple.py

from __future__ import absolute_import, division, print_function, unicode_literals

import argparse
import tensorflow as tf
import numpy as np

def train():
    print("TensorFlow version: ", tf.__version__)

    parser = argparse.ArgumentParser()
    parser.add_argument('--learning_rate', default=0.01, type=float)
    parser.add_argument('--dropout', default=0.2, type=float)
    args = parser.parse_args()

    mnist = tf.keras.datasets.mnist

    (x_train, y_train), (x_test, y_test) = mnist.load_data()
    x_train, x_test = x_train / 255.0, x_test / 255.0

    # Reserve 10,000 samples for validation
    x_val = x_train[-10000:]
    y_val = y_train[-10000:]
    x_train = x_train[:-10000]
    y_train = y_train[:-10000]

    model = tf.keras.models.Sequential([
      tf.keras.layers.Flatten(input_shape=(28, 28)),
      tf.keras.layers.Dense(128, activation='relu'),
      tf.keras.layers.Dropout(args.dropout),
      tf.keras.layers.Dense(10, activation='softmax')
    ])

    model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=args.learning_rate),
                  loss='sparse_categorical_crossentropy',
                  metrics=['accuracy'])

    print("Training...")
    training_history = model.fit(x_train, y_train, epochs=5, validation_data=(x_val, y_val))

    print("Average test loss: ", np.average(training_history.history['loss']))


if __name__ == '__main__':
    train()

모델 컨테이너 이미지 만들기

모델 학습용 컨테이너 이미지를 만들기 위해서 Dockerfile을 생성하겠습니다.

다음은 텐서플로우 2.1을 기반 이미지로 해서, 모델 파일을 추가하는 Dockerfile 입니다.

Dockerfile

FROM tensorflow/tensorflow:2.1.0-py3

RUN mkdir -p /app
ADD mnist-simple.py /app/

다음 명령어로 “kangwoo/mnist-simple:katib” 라는 이름으로 컨테이너 이미지를 빌드할 수 있습니다.

docker build -t kangwoo/mnist-simple:katib.

빌드한 컨테이너 이미지를 컨테이너 이미지 레지스트리에 푸시합니다.

docker push kangwoo/mnist-simple:katib

Experiment 생성하기

Experiment라는 사용자 리소스를 정의합니다. metricsCollectorSpec 필드에 filter가 추가되어 있습니다.

random-stdout-filter-example.yaml

apiVersion: "kubeflow.org/v1alpha3"
kind: Experiment
metadata:
  namespace: admin
  name: random-stdout-filter-example
spec:
  metricsCollectorSpec:
    collector:
      kind: StdOut
    source:
      filter:
        metricsFormat:
          - "([\\\\w|-]+)\\\\s*:\\\\s*((-?\\\\d+)(\\\\.\\\\d+)?)"
  parallelTrialCount: 1
  maxTrialCount: 12
  maxFailedTrialCount: 3
  objective:
    type: maximize
    goal: 0.99
    objectiveMetricName: val_accuracy
    additionalMetricNames:
      - accuracy
  algorithm:
    algorithmName: random
  parameters:
    - name: --learning_rate
      parameterType: double
      feasibleSpace:
        min: "0.01"
        max: "0.2"
    - name: --dropout
      parameterType: double
      feasibleSpace:
        min: "0.1"
        max: "0.5"
  trialTemplate:
    goTemplate:
        rawTemplate: |-
          apiVersion: batch/v1
          kind: Job
          metadata:
            name: {{.Trial}}
            namespace: {{.NameSpace}}
          spec:
            template:
              spec:
                containers:
                - name: {{.Trial}}
                  image: kangwoo/mnist-simple:katib
                  imagePullPolicy: Always
                  command:
                  - "python3"
                  - "/app/mnist-simple.py"
                  {{- with .HyperParameters}}
                  {{- range .}}
                  - "{{.Name}}={{.Value}}"
                  {{- end}}
                  {{- end}}
                restartPolicy: Never

정의한 Experiment 사용자 리소스를 쿠버네티스 클러스터에 생성합니다.

kubectl apply -f random-stdout-filter-example.yaml

Experiment 결과 보기

Katib UI를 통해서 다음과 같은 결과를 확인할 수 있습니다.


TensorFlowEvent 메트릭 수집기 사용하기

TensorFlowEvent 메트릭 수집기를 사용해 보겠습니다. TensorFlowEvent 메트릭 수집기는 텐서플로우에서 생성하는 이벤트를 추출해서 메트릭을 수집합니다. 그래서 기존의 텐서플로우 코드를 사용할 때 유용합니다. 다만 혀재는 텐서플로우 1 버전만을 지원하기 때문에, 텐서플로우 2 버전에 사용하기에는 약간의 문제가 있습니다.

다음은 TensorFlowEvent 메트릭 수집기를 사용하는 metricsCollectorSpec 입니다. fileSystemPath 필드를 사용해서 이벤트가 저장되어 있는 경로를 지정해 주어야합니다.

metricsCollectorSpec:
    collector:
      kind: TensorFlowEvent
    source:
      fileSystemPath:
        path: /train
        kind: Directory

모델 코드 만들기

텐서플로우 1 버전으로 작성한 mnist 숫자를 판별하는 모델입니다. tf.summary를 사용하여 이벤트를 출력하고 있습니다.

mnist-with-summaries.py

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import argparse
import os
import sys

import tensorflow as tf

from tensorflow.examples.tutorials.mnist import input_data

FLAGS = None


def train():
  # Import data
  mnist = input_data.read_data_sets(FLAGS.data_dir,
                                    fake_data=FLAGS.fake_data)

  sess = tf.InteractiveSession()
  # Create a multilayer model.

  # Input placeholders
  with tf.name_scope('input'):
    x = tf.placeholder(tf.float32, [None, 784], name='x-input')
    y_ = tf.placeholder(tf.int64, [None], name='y-input')

  with tf.name_scope('input_reshape'):
    image_shaped_input = tf.reshape(x, [-1, 28, 28, 1])
    tf.summary.image('input', image_shaped_input, 10)

  # We can't initialize these variables to 0 - the network will get stuck.
  def weight_variable(shape):
    """Create a weight variable with appropriate initialization."""
    initial = tf.truncated_normal(shape, stddev=0.1)
    return tf.Variable(initial)

  def bias_variable(shape):
    """Create a bias variable with appropriate initialization."""
    initial = tf.constant(0.1, shape=shape)
    return tf.Variable(initial)

  def variable_summaries(var):
    """Attach a lot of summaries to a Tensor (for TensorBoard visualization)."""
    with tf.name_scope('summaries'):
      mean = tf.reduce_mean(var)
      tf.summary.scalar('mean', mean)
      with tf.name_scope('stddev'):
        stddev = tf.sqrt(tf.reduce_mean(tf.square(var - mean)))
      tf.summary.scalar('stddev', stddev)
      tf.summary.scalar('max', tf.reduce_max(var))
      tf.summary.scalar('min', tf.reduce_min(var))
      tf.summary.histogram('histogram', var)

  def nn_layer(input_tensor, input_dim, output_dim, layer_name, act=tf.nn.relu):
    """Reusable code for making a simple neural net layer.
    It does a matrix multiply, bias add, and then uses ReLU to nonlinearize.
    It also sets up name scoping so that the resultant graph is easy to read,
    and adds a number of summary ops.
    """
    # Adding a name scope ensures logical grouping of the layers in the graph.
    with tf.name_scope(layer_name):
      # This Variable will hold the state of the weights for the layer
      with tf.name_scope('weights'):
        weights = weight_variable([input_dim, output_dim])
        variable_summaries(weights)
      with tf.name_scope('biases'):
        biases = bias_variable([output_dim])
        variable_summaries(biases)
      with tf.name_scope('Wx_plus_b'):
        preactivate = tf.matmul(input_tensor, weights) + biases
        tf.summary.histogram('pre_activations', preactivate)
      activations = act(preactivate, name='activation')
      tf.summary.histogram('activations', activations)
      return activations

  hidden1 = nn_layer(x, 784, 500, 'layer1')

  with tf.name_scope('dropout'):
    keep_prob = tf.placeholder(tf.float32)
    tf.summary.scalar('dropout_keep_probability', keep_prob)
    dropped = tf.nn.dropout(hidden1, keep_prob)

  # Do not apply softmax activation yet, see below.
  y = nn_layer(dropped, 500, 10, 'layer2', act=tf.identity)

  with tf.name_scope('cross_entropy'):
    # The raw formulation of cross-entropy,
    #
    # tf.reduce_mean(-tf.reduce_sum(y_ * tf.log(tf.softmax(y)),
    #                               reduction_indices=[1]))
    #
    # can be numerically unstable.
    #
    # So here we use tf.losses.sparse_softmax_cross_entropy on the
    # raw logit outputs of the nn_layer above, and then average across
    # the batch.
    with tf.name_scope('total'):
      cross_entropy = tf.losses.sparse_softmax_cross_entropy(
          labels=y_, logits=y)
  tf.summary.scalar('cross_entropy', cross_entropy)

  with tf.name_scope('train'):
    train_step = tf.train.AdamOptimizer(FLAGS.learning_rate).minimize(
        cross_entropy)

  with tf.name_scope('accuracy'):
    with tf.name_scope('correct_prediction'):
      correct_prediction = tf.equal(tf.argmax(y, 1), y_)
    with tf.name_scope('accuracy'):
      accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))
  tf.summary.scalar('accuracy', accuracy)

  # Merge all the summaries and write them out to
  # /tmp/tensorflow/mnist/logs/mnist_with_summaries (by default)
  merged = tf.summary.merge_all()
  train_writer = tf.summary.FileWriter(FLAGS.log_dir + '/train', sess.graph)
  test_writer = tf.summary.FileWriter(FLAGS.log_dir + '/test')
  tf.global_variables_initializer().run()

  # Train the model, and also write summaries.
  # Every 10th step, measure test-set accuracy, and write test summaries
  # All other steps, run train_step on training data, & add training summaries

  def feed_dict(train):     # pylint: disable=redefined-outer-name
    """Make a TensorFlow feed_dict: maps data onto Tensor placeholders."""
    if train or FLAGS.fake_data:
      xs, ys = mnist.train.next_batch(FLAGS.batch_size, fake_data=FLAGS.fake_data)
      k = FLAGS.dropout
    else:
      xs, ys = mnist.test.images, mnist.test.labels
      k = 1.0
    return {x: xs, y_: ys, keep_prob: k}

  for i in range(FLAGS.max_steps):
    if i % 10 == 0:  # Record summaries and test-set accuracy
      summary, acc = sess.run([merged, accuracy], feed_dict=feed_dict(False))
      test_writer.add_summary(summary, i)
      print('Accuracy at step %s: %s' % (i, acc))
    else:  # Record train set summaries, and train
      if i % 100 == 99:  # Record execution stats
        run_options = tf.RunOptions(trace_level=tf.RunOptions.FULL_TRACE)
        run_metadata = tf.RunMetadata()
        summary, _ = sess.run([merged, train_step],
                              feed_dict=feed_dict(True),
                              options=run_options,
                              run_metadata=run_metadata)
        train_writer.add_run_metadata(run_metadata, 'step%03d' % i)
        train_writer.add_summary(summary, i)
        print('Adding run metadata for', i)
      else:  # Record a summary
        summary, _ = sess.run([merged, train_step], feed_dict=feed_dict(True))
        train_writer.add_summary(summary, i)
  train_writer.close()
  test_writer.close()


def main(_):
  if tf.gfile.Exists(FLAGS.log_dir):
    tf.gfile.DeleteRecursively(FLAGS.log_dir)
  tf.gfile.MakeDirs(FLAGS.log_dir)
  train()


if __name__ == '__main__':
  parser = argparse.ArgumentParser()
  parser.add_argument('--fake_data', nargs='?', const=True, type=bool,
                      default=False,
                      help='If true, uses fake data for unit testing.')
  parser.add_argument('--max_steps', type=int, default=1000,
                      help='Number of steps to run trainer.')
  parser.add_argument('--learning_rate', type=float, default=0.001,
                      help='Initial learning rate')
  parser.add_argument('--batch_size', type=int, default=100,
                      help='Training batch size')
  parser.add_argument('--dropout', type=float, default=0.9,
                      help='Keep probability for training dropout.')
  parser.add_argument(
      '--data_dir',
      type=str,
      default=os.path.join(os.getenv('TEST_TMPDIR', '/tmp'),
                           'tensorflow/mnist/input_data'),
      help='Directory for storing input data')
  parser.add_argument(
      '--log_dir',
      type=str,
      default=os.path.join(os.getenv('TEST_TMPDIR', '/tmp'),
                           'tensorflow/mnist/logs/mnist_with_summaries'),
      help='Summaries log directory')
  FLAGS, unparsed = parser.parse_known_args()
  tf.app.run(main=main, argv=[sys.argv[0]] + unparsed)

모델 컨테이너 이미지 만들기

모델 학습용 컨테이너 이미지를 만들기 위해서 Dockerfile을 생성하겠습니다.

다음은 텐서플로우 1.11을 기반 이미지로 해서, 모델 파일을 추가하는 Dockerfile 입니다.

Dockerfile

FROM tensorflow/tensorflow:1.11.0

RUN mkdir -p /app
ADD mnist-with-summaries.py /app/

다음 명령어로 “kangwoo/mnist-simple:katib” 라는 이름으로 컨테이너 이미지를 빌드할 수 있습니다.

docker build -t kangwoo/mnist-with-summaries:katib .

빌드한 컨테이너 이미지를 컨테이너 이미지 레지스트리에 푸시합니다.

docker push kangwoo/mnist-with-summaries:katib

Experiment 생성하기

Experiment라는 사용자 리소스를 정의합니다. metricsCollectorSpec 필드에 filter가 추가되어 있습니다.

random-tf-event-example.yaml

apiVersion: "kubeflow.org/v1alpha3"
kind: Experiment
metadata:
  namespace: admin
  name: random-tf-event-example
spec:
  metricsCollectorSpec:
    source:
      fileSystemPath:
        path: /train
        kind: Directory
    collector:
      kind: TensorFlowEvent
  parallelTrialCount: 1
  maxTrialCount: 12
  maxFailedTrialCount: 3
  objective:
    type: maximize
    goal: 0.99
    objectiveMetricName: accuracy_1
  algorithm:
    algorithmName: random
  parameters:
    - name: --learning_rate
      parameterType: double
      feasibleSpace:
        min: "0.01"
        max: "0.05"
    - name: --batch_size
      parameterType: int
      feasibleSpace:
        min: "100"
        max: "200"
  trialTemplate:
    goTemplate:
        rawTemplate: |-
          apiVersion: "kubeflow.org/v1"
          kind: TFJob
          metadata:
            name: {{.Trial}}
            namespace: {{.NameSpace}}
          spec:
           tfReplicaSpecs:
            Worker:
              replicas: 1
              restartPolicy: OnFailure
              template:
                spec:
                  containers:
                    - name: tensorflow
                      image: kangwoo/mnist-with-summaries:katib
                      imagePullPolicy: Always
                      command:
                        - "python"
                        - "/app/mnist-with-summaries.py"
                        - "--log_dir=/train/metrics"
                        {{- with .HyperParameters}}
                        {{- range .}}
                        - "{{.Name}}={{.Value}}"
                        {{- end}}
                        {{- end}}

정의한 Experiment 사용자 리소스를 쿠버네티스 클러스터에 생성합니다.

kubectl apply -f random-tf-event-example.yaml

Experiment 결과 보기

Katib UI를 통해서 다음과 같은 결과를 확인할 수 있습니다.


File 메트릭 수집기 사용하기

File 메트릭 수집기를 사용해 보겠습니다. File 메트릭 수집기는 파일로 출력되는 로그를 추출해서 메트릭을 수집합니다. File 메트릭 수집기도 필터를 사용하여 메트릭 형식을 지정할 수 있습니다. 메트릭 형식을 지정하지 않으면, 기본 형식인 “([\w|-]+)\s*=\s*((-?\d+)(\.\d+)?)” 즉 {{MetricsName}}={{MetricsValue}} 을 사용합니다.

다음은 File 메트릭 수집기를 사용하는 metricsCollectorSpec 입니다. fileSystemPath 필드를 사용해서 로그가 저장되어 있는 파일 경로를 지정해 주어야 합니다. 파일 경로를 지정하지 않으면 기본 경로인 “/var/log/katib/metrics.log”을 사용합니다.

metricsCollectorSpec:
    source:
      filter:
        metricsFormat:
        - "([\\\\w|-]+)\\\\s*=\\\\s*((-?\\\\d+)(\\\\.\\\\d+)?)"
      fileSystemPath:
        path: "/var/log/katib/mnist.log"
        kind: File
    collector:
      kind: File

모델 코드 만들기

텐서플로우 케라스로 작성한 mnist 숫자를 판별하는 모델입니다. logging 패키지를 사용하여 파일로 로그를 출력하고 있습니다.

mnist-with-log.py

from __future__ import absolute_import, division, print_function, unicode_literals

import tensorflow as tf
import argparse
import numpy as np
from datetime import datetime, timezone

import logging

logging.basicConfig(filename='/var/log/katib/mnist.log', level=logging.DEBUG)


def train():
    print("TensorFlow version: ", tf.__version__)

    parser = argparse.ArgumentParser()
    parser.add_argument('--learning_rate', default=0.01, type=float)
    parser.add_argument('--dropout', default=0.2, type=float)
    args = parser.parse_args()

    mnist = tf.keras.datasets.mnist

    (x_train, y_train), (x_test, y_test) = mnist.load_data()
    x_train, x_test = x_train / 255.0, x_test / 255.0

    # Reserve 10,000 samples for validation
    x_val = x_train[-10000:]
    y_val = y_train[-10000:]
    x_train = x_train[:-10000]
    y_train = y_train[:-10000]

    model = tf.keras.models.Sequential([
      tf.keras.layers.Flatten(input_shape=(28, 28)),
      tf.keras.layers.Dense(128, activation='relu'),
      tf.keras.layers.Dropout(args.dropout),
      tf.keras.layers.Dense(10, activation='softmax')
    ])

    model.compile(optimizer=tf.keras.optimizers.SGD(learning_rate=args.learning_rate),
                  loss='sparse_categorical_crossentropy',
                  metrics=['acc'])

    print("Training...")
    katib_metric_log_callback = KatibMetricLog()
    training_history = model.fit(x_train, y_train, batch_size=64, epochs=10,
                                 validation_data=(x_val, y_val),
                                 callbacks=[katib_metric_log_callback])
    print("Average test loss: ", np.average(training_history.history['loss']))


class KatibMetricLog(tf.keras.callbacks.Callback):
    def on_epoch_end(self, epoch, logs=None):
        # RFC 3339
        local_time = datetime.now(timezone.utc).astimezone().isoformat()
        logging.info("\\n{} accuracy={:.4f} loss={:.4f} Validation-accuracy={:.4f} Validation-loss={:.4f}"
                     .format(local_time, logs['acc'], logs['loss'], logs['val_acc'], logs['val_loss']))


if __name__ == '__main__':
    train()

모델 컨테이너 이미지 만들기

모델 학습용 컨테이너 이미지를 만들기 위해서 Dockerfile을 생성하겠습니다.

다음은 텐서플로우 2.1을 기반 이미지로 해서, 모델 파일을 추가하는 Dockerfile 입니다.

Dockerfile

FROM tensorflow/tensorflow:2.1.0-py3

RUN mkdir -p /app
ADD mnist-with-log.py /app/

다음 명령어로 “kangwoo/mnist-with-log:katib” 라는 이름으로 컨테이너 이미지를 빌드할 수 있습니다.

docker build -t kangwoo/mnist-with-log:katib.

빌드한 컨테이너 이미지를 컨테이너 이미지 레지스트리에 푸시합니다.

docker push kangwoo/mnist-with-log:katib

Experiment 생성하기

Experiment라는 사용자 리소스를 정의합니다. metricsCollectorSpec 필드에 filter가 추가되어 있습니다.

random-tf-event-example.yaml

apiVersion: "kubeflow.org/v1alpha3"
kind: Experiment
metadata:
  namespace: admin
  name: random-file-example
spec:
  metricsCollectorSpec:
    source:
      fileSystemPath:
        path: "/var/log/katib/mnist.log"
        kind: File
    collector:
      kind: File
  parallelTrialCount: 1
  maxTrialCount: 12
  maxFailedTrialCount: 3
  objective:
    type: maximize
    goal: 0.99
    objectiveMetricName: Validation-accuracy
    additionalMetricNames:
      - accuracy
  algorithm:
    algorithmName: random
  parameters:
    - name: --learning_rate
      parameterType: double
      feasibleSpace:
        min: "0.01"
        max: "0.2"
    - name: --dropout
      parameterType: double
      feasibleSpace:
        min: "0.1"
        max: "0.5"
  trialTemplate:
    goTemplate:
        rawTemplate: |-
          apiVersion: batch/v1
          kind: Job
          metadata:
            name: {{.Trial}}
            namespace: {{.NameSpace}}
          spec:
            template:
              spec:
                containers:
                - name: {{.Trial}}
                  image: kangwoo/mnist-with-log:katib
                  imagePullPolicy: Always
                  command:
                  - "python3"
                  - "/app/mnist-with-log.py"
                  {{- with .HyperParameters}}
                  {{- range .}}
                  - "{{.Name}}={{.Value}}"
                  {{- end}}
                  {{- end}}
                restartPolicy: Never

정의한 Experiment 사용자 리소스를 쿠버네티스 클러스터에 생성합니다.

kubectl apply -f random-tf-event-example.yaml

Experiment 결과 보기

Katib UI를 통해서 다음과 같은 결과를 확인할 수 있습니다.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2d0f4811-7203-4ef7-bfd2-8dd2123f35d3/Untitled.png

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다