Slide 1

Slide 1 text

©2018 Wantedly, Inc. ConfigMap vs Secret ίʔυ͔ΒಡΈղ͘ ConfigMap ͱ4FDSFUͷҧ͍ Kubernetes Meetup Tokyo #14 8.Nov.2018 - Shimpei Otsubo - @potsbo

Slide 2

Slide 2 text

ConfigMapͱSecret͸Կ͕ҧ͏ʁ ͲͬͪΛ࢖͏͔͸͙͢Θ͔Δ ڍಈ͸͘͢͝ࣅͯΔ άάͬͯ΋Α͘Θ͔Βͳ͍ ࢖͍෼͚͸ॻ͍͍ͯͯ΋໌֬ͳҧ͍͸ॻ͍ͯͳ͍ Ұൠͷ։ൃऀʹݟΒΕͯΑ͍͔ʁ ContainerʹԿ͔Λinject͢Δ ©2018 Wantedly, Inc.

Slide 3

Slide 3 text

ຊ࣭తͳҧ͍͸ίʔυΛಡ·ͳ͍ͱΘ͔Βͳ͍ ©2018 Wantedly, Inc.

Slide 4

Slide 4 text

/* Copyright 2015 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package configmap import ( "fmt" "github.com/golang/glog" "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/kubernetes/pkg/util/mount" "k8s.io/kubernetes/pkg/util/strings" "k8s.io/kubernetes/pkg/volume" volumeutil "k8s.io/kubernetes/pkg/volume/util" ) // ProbeVolumePlugins is the entry point for plugin detection in a package. func ProbeVolumePlugins() []volume.VolumePlugin { return []volume.VolumePlugin{&configMapPlugin{}} } const ( configMapPluginName = "kubernetes.io/configmap" ) // configMapPlugin implements the VolumePlugin interface. type configMapPlugin struct { host volume.VolumeHost /* Copyright 2015 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package secret import ( "fmt" "github.com/golang/glog" "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/kubernetes/pkg/util/mount" "k8s.io/kubernetes/pkg/util/strings" "k8s.io/kubernetes/pkg/volume" volumeutil "k8s.io/kubernetes/pkg/volume/util" ) // ProbeVolumePlugins is the entry point for plugin detection in a package. func ProbeVolumePlugins() []volume.VolumePlugin { return []volume.VolumePlugin{&secretPlugin{}} } const ( secretPluginName = "kubernetes.io/secret" ) // secretPlugin implements the VolumePlugin interface. type secretPlugin struct { host volume.VolumeHost ©2018 Wantedly, Inc.

Slide 5

Slide 5 text

fileProjection.Mode = *defaultMode } payload[ktp.Path] = fileProjection } } return payload, nil } func totalSecretBytes(secret *v1.Secret) int { totalSize := 0 for _, value := range secret.Data { totalSize += len(value) } return totalSize } // secretVolumeUnmounter handles cleaning up secret volumes. type secretVolumeUnmounter struct { *secretVolume } var _ volume.Unmounter = &secretVolumeUnmounter{} func (c *secretVolumeUnmounter) TearDown() error { return c.TearDownAt(c.GetPath()) } func (c *secretVolumeUnmounter) TearDownAt(dir string) error { return volumeutil.UnmountViaEmptyDir(dir, c.plugin.host, c.volName, wrappedVolumeSpec(), c.podUID) } func getVolumeSource(spec *volume.Spec) (*v1.SecretVolumeSource, bool) { var readOnly bool var volumeSource *v1.SecretVolumeSource if spec.Volume != nil && spec.Volume.Secret != nil { volumeSource = spec.Volume.Secret readOnly = spec.ReadOnly } return volumeSource, readOnly } } return payload, nil } func totalBytes(configMap *v1.ConfigMap) int { totalSize := 0 for _, value := range configMap.Data { totalSize += len(value) } for _, value := range configMap.BinaryData { totalSize += len(value) } return totalSize } // configMapVolumeUnmounter handles cleaning up configMap volumes. type configMapVolumeUnmounter struct { *configMapVolume } var _ volume.Unmounter = &configMapVolumeUnmounter{} func (c *configMapVolumeUnmounter) TearDown() error { return c.TearDownAt(c.GetPath()) } func (c *configMapVolumeUnmounter) TearDownAt(dir string) error { return volumeutil.UnmountViaEmptyDir(dir, c.plugin.host, c.volName, wrappedVolumeSpec(), c.podUID) } func getVolumeSource(spec *volume.Spec) (*v1.ConfigMapVolumeSource, bool) { var readOnly bool var volumeSource *v1.ConfigMapVolumeSource if spec.Volume != nil && spec.Volume.ConfigMap != nil { volumeSource = spec.Volume.ConfigMap readOnly = spec.ReadOnly } return volumeSource, readOnly } ©2018 Wantedly, Inc.

Slide 6

Slide 6 text

fileProjection.Mode = *defaultMode } payload[ktp.Path] = fileProjection } } return payload, nil } func totalSecretBytes(secret *v1.Secret) int { totalSize := 0 for _, value := range secret.Data { totalSize += len(value) } return totalSize } // secretVolumeUnmounter handles cleaning up secret volumes. type secretVolumeUnmounter struct { *secretVolume } var _ volume.Unmounter = &secretVolumeUnmounter{} func (c *secretVolumeUnmounter) TearDown() error { return c.TearDownAt(c.GetPath()) } func (c *secretVolumeUnmounter) TearDownAt(dir string) error { return volumeutil.UnmountViaEmptyDir(dir, c.plugin.host, c.volName, wrappedVolumeSpec(), c.podUID) } func getVolumeSource(spec *volume.Spec) (*v1.SecretVolumeSource, bool) { var readOnly bool var volumeSource *v1.SecretVolumeSource if spec.Volume != nil && spec.Volume.Secret != nil { volumeSource = spec.Volume.Secret readOnly = spec.ReadOnly } return volumeSource, readOnly } } return payload, nil } func totalBytes(configMap *v1.ConfigMap) int { totalSize := 0 for _, value := range configMap.Data { totalSize += len(value) } for _, value := range configMap.BinaryData { totalSize += len(value) } return totalSize } // configMapVolumeUnmounter handles cleaning up configMap volumes. type configMapVolumeUnmounter struct { *configMapVolume } var _ volume.Unmounter = &configMapVolumeUnmounter{} func (c *configMapVolumeUnmounter) TearDown() error { return c.TearDownAt(c.GetPath()) } func (c *configMapVolumeUnmounter) TearDownAt(dir string) error { return volumeutil.UnmountViaEmptyDir(dir, c.plugin.host, c.volName, wrappedVolumeSpec(), c.podUID) } func getVolumeSource(spec *volume.Spec) (*v1.ConfigMapVolumeSource, bool) { var readOnly bool var volumeSource *v1.ConfigMapVolumeSource if spec.Volume != nil && spec.Volume.ConfigMap != nil { volumeSource = spec.Volume.ConfigMap readOnly = spec.ReadOnly } return volumeSource, readOnly } ©2018 Wantedly, Inc. ίϐϖͬΆ͍

Slide 7

Slide 7 text

52c52 < Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{}}}, --- > Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{Medium: v1.StorageMediumMemory}}}, 55a56,59 > func getPath(uid types.UID, volumeName string, host volume.VolumeHost) string { > return host.GetPodVolumeDir(uid, strings.EscapeQualifiedNameForDisk(configmapPluginName), volumeName) > } > 72,75c76 < return fmt.Sprintf( < "%v/%v", < spec.Name(), < volumeSource.Name), nil --- > return volumeSource.Name, nil 101c102 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host))), 117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) --- > return getPath(sv.podUID, sv.volumeName, sv.plugin.host) 206c209 < len(configmap.Data)+len(configmap.BinaryData), --- > len(configmap.Data), 265c268 < payload := make(map[string]volumeutil.FileProjection, (len(configmap.Data) + len(configmap.BinaryData))) --- > payload := make(map[string]volumeutil.FileProjection, len(configmap.Data)) 274,278d276 < for name, data := range configmap.BinaryData { < fileProjection.Data = data < fileProjection.Mode = *defaultMode < payload[name] = fileProjection < } 281,285c279,280 < if stringData, ok := configmap.Data[ktp.Key]; ok { < fileProjection.Data = []byte(stringData) < } else if binaryData, ok := configmap.BinaryData[ktp.Key]; ok { < fileProjection.Data = binaryData < } else { --- > content, ok := configmap.Data[ktp.Key] > if !ok { 289c284,286 < return nil, fmt.Errorf("configmap references non-existent config key: %s", ktp.Key) --- > errMsg := "references non-existent configmap key" > glog.Errorf(errMsg) > return nil, fmt.Errorf(errMsg) 291a289 > fileProjection.Data = []byte(content) 300d297 < 309,311d305 < for _, value := range configmap.BinaryData { < totalSize += len(value) < } ˞EJGG͕গͳ͘ͳΔΑ͏ʹগ͍ͬͯ͠͡·͢

Slide 8

Slide 8 text

52c52 < Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{}}}, --- > Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{Medium: v1.StorageMediumMemory}}}, 55a56,59 > func getPath(uid types.UID, volumeName string, host volume.VolumeHost) string { > return host.GetPodVolumeDir(uid, strings.EscapeQualifiedNameForDisk(configmapPluginName), volumeName) > } > 72,75c76 < return fmt.Sprintf( < "%v/%v", < spec.Name(), < volumeSource.Name), nil --- > return volumeSource.Name, nil 101c102 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host))), 117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName)

Slide 9

Slide 9 text

52c52 < Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{}}}, --- > Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{Medium: v1.StorageMediumMemory}}}, 55a56,59 > func getPath(uid types.UID, volumeName string, host volume.VolumeHost) string { > return host.GetPodVolumeDir(uid, strings.EscapeQualifiedNameForDisk(configmapPluginName), volumeName) > } > 72,75c76 < return fmt.Sprintf( < "%v/%v", < spec.Name(), < volumeSource.Name), nil --- > return volumeSource.Name, nil 101c102 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host))), 117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) 4FDSFU͸ϝϞϦ಺

Slide 10

Slide 10 text

52c52 < Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{}}}, --- > Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{Medium: v1.StorageMediumMemory}}}, 55a56,59 > func getPath(uid types.UID, volumeName string, host volume.VolumeHost) string { > return host.GetPodVolumeDir(uid, strings.EscapeQualifiedNameForDisk(configmapPluginName), volumeName) > } > 72,75c76 < return fmt.Sprintf( < "%v/%v", < spec.Name(), < volumeSource.Name), nil --- > return volumeSource.Name, nil 101c102 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host))), 117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) ಉҰͷ$POGJH.BQෳ਺ճBUUBDIग़དྷΔΑ͏ʹ IUUQTHJUIVCDPNLVCFSOFUFTLVCFSOFUFTQVMM ˞4FDSFUͰ͸मਖ਼͞Ε͍ͯͳ͍͕͜ͷ໰୊͸ى͖ͳ͔ͬͨ

Slide 11

Slide 11 text

52c52 < Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{}}}, --- > Volume: &v1.Volume{VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{Medium: v1.StorageMediumMemory}}}, 55a56,59 > func getPath(uid types.UID, volumeName string, host volume.VolumeHost) string { > return host.GetPodVolumeDir(uid, strings.EscapeQualifiedNameForDisk(configmapPluginName), volumeName) > } > 72,75c76 < return fmt.Sprintf( < "%v/%v", < spec.Name(), < volumeSource.Name), nil --- > return volumeSource.Name, nil 101c102 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host))), 117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) 4FDSFU͸.FUSJDT͕औΕΔ

Slide 12

Slide 12 text

> return host.GetPodVolumeDir(uid, strings.EscapeQualifiedNameForDisk(configmapPluginName), volumeName) > } > 72,75c76 < return fmt.Sprintf( < "%v/%v", < spec.Name(), < volumeSource.Name), nil --- > return volumeSource.Name, nil 101c102 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(pod.UID, spec.Name(), plugin.host))), 117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) --- > return getPath(sv.podUID, sv.volumeName, sv.plugin.host) 206c209 < len(configmap.Data)+len(configmap.BinaryData), --- > len(configmap.Data), 265c268 < payload := make(map[string]volumeutil.FileProjection, (len(configmap.Data) + len(configmap.BinaryData))) ʁ

Slide 13

Slide 13 text

117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) --- > return getPath(sv.podUID, sv.volumeName, sv.plugin.host) 206c209 < len(configmap.Data)+len(configmap.BinaryData), --- > len(configmap.Data), 265c268 < payload := make(map[string]volumeutil.FileProjection, (len(configmap.Data) + len(configmap.BinaryData))) --- > payload := make(map[string]volumeutil.FileProjection, len(configmap.Data)) 274,278d276 < for name, data := range configmap.BinaryData { < fileProjection.Data = data < fileProjection.Mode = *defaultMode < payload[name] = fileProjection < } 281,285c279,280 < if stringData, ok := configmap.Data[ktp.Key]; ok { < fileProjection.Data = []byte(stringData) < } else if binaryData, ok := configmap.BinaryData[ktp.Key]; ok { < fileProjection.Data = binaryData < } else { --- 4FDSFU͸.FUSJDT͕औΕΔ

Slide 14

Slide 14 text

117c118 < volume.MetricsNil{}, --- > volume.NewCachedMetrics(volume.NewMetricsDu(getPath(podUID, volumeName, plugin.host))), 126c127,129 < configmap: &v1.configmapVolumeSource{}, --- > configmap: &v1.configmapVolumeSource{ > Name: volumeName, > }, 137c140 < volume.MetricsNil --- > volume.MetricsProvider 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) --- > return getPath(sv.podUID, sv.volumeName, sv.plugin.host) 206c209 < len(configmap.Data)+len(configmap.BinaryData), --- > len(configmap.Data), 265c268 < payload := make(map[string]volumeutil.FileProjection, (len(configmap.Data) + len(configmap.BinaryData))) --- > payload := make(map[string]volumeutil.FileProjection, len(configmap.Data)) 274,278d276 < for name, data := range configmap.BinaryData { < fileProjection.Data = data < fileProjection.Mode = *defaultMode < payload[name] = fileProjection < } 281,285c279,280 < if stringData, ok := configmap.Data[ktp.Key]; ok { < fileProjection.Data = []byte(stringData) < } else if binaryData, ok := configmap.BinaryData[ktp.Key]; ok { < fileProjection.Data = binaryData < } else { --- ݁ہҰॹʹͳΔ΍ͭ

Slide 15

Slide 15 text

> return getPath(sv.podUID, sv.volumeName, sv.plugin.host) 206c209 < len(configmap.Data)+len(configmap.BinaryData), --- > len(configmap.Data), 265c268 < payload := make(map[string]volumeutil.FileProjection, (len(configmap.Data) + len(configmap.BinaryData))) --- > payload := make(map[string]volumeutil.FileProjection, len(configmap.Data)) 274,278d276 < for name, data := range configmap.BinaryData { < fileProjection.Data = data < fileProjection.Mode = *defaultMode < payload[name] = fileProjection < } 281,285c279,280 < if stringData, ok := configmap.Data[ktp.Key]; ok { < fileProjection.Data = []byte(stringData) < } else if binaryData, ok := configmap.BinaryData[ktp.Key]; ok { < fileProjection.Data = binaryData < } else { --- > content, ok := configmap.Data[ktp.Key] > if !ok { 289c284,286 < return nil, fmt.Errorf("configmap references non-existent config key: %s", ktp.Key) --- > errMsg := "references non-existent configmap key" > glog.Errorf(errMsg) > return nil, fmt.Errorf(errMsg) 291a289 > fileProjection.Data = []byte(content) 300d297 < 309,311d305 < for _, value := range configmap.BinaryData { < totalSize += len(value) < } $POGJH.BQ͸#JOBSZ%BUBʹରԠ

Slide 16

Slide 16 text

Secret͸Metrics͕औΕΔ ©2018 Wantedly, Inc. ConfigMap͸string΋औΕΔ

Slide 17

Slide 17 text

ޙ͸Ұॹʂʂ ©2018 Wantedly, Inc.

Slide 18

Slide 18 text

͡Ό͋ͳΜͰ෼͔ΕͯΔͷ͔ʁ ໊લ͕෼͔Ε͍ͯΔͱศརͳ͜ͱ͕͋Δ etcd ͰϦιʔε୯ҐͰ҉߸Խ͢Δ͔ܾΊΒΕΔʁ ©2018 Wantedly, Inc. 3#"$ͰݖݶΛ෼͚΍͍͢Α͏ʹʁ

Slide 19

Slide 19 text

ConfigMap Λίϐϖͨͬ͠Ά͍৔ॴ ©2018 Wantedly, Inc. 143c146 < return sv.plugin.host.GetPodVolumeDir(sv.podUID, strings.EscapeQualifiedNameForDisk(configmapPluginName), sv.volumeName) --- > return getPath(sv.podUID, sv.volumeName, sv.plugin.host) sv ͬͯ secret volume? // configMapVolumeMounter handles retrieving secret from the API server // and placing them into the volume on the host. type configMapVolumeMounter struct { *configMapVolume source v1.ConfigMapVolumeSource pod v1.Pod opts *volume.VolumeOptions getConfigMap func(namespace, name string) (*v1.ConfigMap, error) } secret ͬͯ configmap.go ʹॻ͍ͯ͋Δ ༨ஊ1

Slide 20

Slide 20 text

Secret͚ͩMetricsऔΕΔͷͳΜͰʁ ©2018 Wantedly, Inc. 1/15 2016 branch 2/6 2016 Secret ʹ Metrics ෇༩ merge 1/26 2016 branch Secret ͔Β ConfigMap ࡞੒ 2/15 2016 merge PR #19741 PR #20114 PR ͷλΠϛϯάͷ໰୊Ͱ͸ʁ ༨ஊ2

Slide 21

Slide 21 text

·ͱΊ $POGJH.BQͱ4FDSFU͸ΊͪΌࣅͯΔ ػೳతʹ΄ͱΜͲҧ͍͸ͳ͍ ίʔυಡΈղ͘ͱ͓΋͠Ζࣄ࣮͕ग़ͯ͘Δ ©2018 Wantedly, Inc.