OSSCA ArgoWorkflows 프로젝트 멘토님께서 본인이 이전에 작성한 블로그로 컨테이너 구조에대해 발표를 해주셨다.
https://ykarma1996.tistory.com/192
리눅스의 네임스페이스까지는 격리해서 실습을 해보았지만, 컨테이너 오버레이에 대해서 까지는 생각을 못했었다.
이김에 멘토님의 블로그를 따라하며 오버레이 실습을 해보려고 한다! (**내용은 위의 근삼님 블로그 내용과 동일하다)
멘토님이 해당 블로그를 작성하게 된 계기는 컨테이너 이미지 리모트 캐시에 대해 알아보다가 컨테이너가 어떻게 구성되고 동작하는지 이해하기 위해서 였다고 한다.
docker in docker 빌드, Argo Workflows 또는 tekton으로 빌드할 경우 빌드시 매번 초기화돼서 깔끔하고 좋다는 장점이 있지만, 빌드시 캐시를 잃어버려 빌드할 때마다 시간이 오래걸린다. 특히 jvm의 경우 빌드 시간이 정말 오래걸리는데, 빌드캐시간 안되면 10분씩 손해를 보기도 한다. 이 문제를 해결하는 방법 중 하나가 리모트 캐시를 활용하는것이다. 리모트 캐시를 지원하는 빌더도 다양하다. 뭘 쓰는 것이 좋을지 찾기 위해 리서치를 시작했다.
OverlayFS
컨테이너 생태계는 OverlayFS의 개념을 적극적으로 활용하여 컨테이너 이미지를 만들고 주고 받을 때, 필요한 부분만 주고받아서, 공통되는 레이어는 작업하지 않도록 하여 작업량을 최대한 줄인다고 한다.
OverlayFS라는 개념은 컨테이너만을 위한 개념이 아니라고 한다. 리눅스에서 하나의 디렉터리 지점에 여러개의 디렉터리를 마운트하는 방식으로, 마치 하나의 통합된 디렉터리처럼 보이게 하는 마운트 방식을 유니온 마운트 파일 시스템이라고 부르는데, OverlayFS는 그에 대한 구현체중 하나라고 한다.
Overlay는 크게 4가지 디렉터리 레이어로 구성된다.
lower dir | - 아래쪽에 깔려있는 수정이 불가능한 R/O 형태의 티렉터리이며 여러개일 수 있다. - 기본 데이터 저장소로 사용되며, 읽기 전용으로 마운트된다 |
upper dir | - lower dir들의 위에 위치하는 수정 사항이 반영되는 R/W 형태의 디렉터리이며 하나이다 - 변경사항을 저장하며 사용자가 파일을 수정하거나 새로운 파일을 생성할 때 이 디렉터리에 저장된다 |
merge dir | - 위의 그림에서, 모든 레이어를 겹쳐서 보는 통합 뷰(view)에 해당하는 디렉터리이며, 사용자의 실질적인 작업영역이 된다 - lower dir 과 upper dir 내용을 결합하여 사용자에게 제공되는 최종 파일 시스템 뷰이다 |
work dir | - 통합 뷰의 원자성을 보장하기 위해 존재하는 중간 계층이며, 사용자에게는 중요하지 않다 - upper dir에서의 파일 조작과 관련된 중간 데이터를 저장한다 - work dir 은 upper dir과 동일한 파일 시스템에 있어야 한다 |
snchoi@snchoi:~/overlayfs-example$ mkdir lower1 lower2 upper merge work
snchoi@snchoi:~/overlayfs-example$ echo nyeong > lower1/file1
snchoi@snchoi:~/overlayfs-example$ echo nyeongnyeong > lower2/file2
두 개의 lower dir을 준비했고, 각 디렉터리에 file1, file2를 생성해두었다.
overlay 형태로 마운트를 시켜보자!
snchoi@snchoi:~/overlayfs-example$ sudo mount -t overlay overlay -o lowerdir=lower1/:lower2/,upperdir=upper/,workdir=work/ merge/
https://wiki.archlinux.org/title/Overlay_filesystem
해당 명령어를 치자마자 아래와 같이 merge 폴더에 lower에 존재하던 파일이 생성되었다. overlay 형태로 마운트가 성공한 것이다!
snchoi@snchoi:~/overlayfs-example$ echo test > upper/test3
upper 폴더에 파일을 추가하면
upper 폴더에 추가됨과 동시에 merge에도 추가되었다.
여기서 merge의 file1을 지우게 되면?
snchoi@snchoi:~/overlayfs-example$ rm -rf merge/file1
merge에서는 제거되고 lower1에는 그대로 남아있으며 upper에 file1이 새로 쌓였다.
파일의 권한을 확인해보니 file1의 맨 앞자리가 c로 변경되었다.
그럼 merge에 있는 lower에서 가져온 파일을 수정하면 어떻게 될까? 해당 파일이 수정됐으므로 upper에 쌓이게 된다.
snchoi@snchoi:~/overlayfs-example$ echo snchoi > merge/file2
그래서 이 구조가 컨테이너에 어떻게 적용돼있다는 것인가?
컨테이너 이미지가 존재하면 docker inspect 명령어를 통해 해당 이미지가 어떤 구조로 돼있는지 확인할 수 있다.
docker inspect 명령을 통해 하나의 이미지를 inspect 해본 결과는 다음과 같다.
snchoi@snchoi:~/overlayfs$ docker inspect sn0716/parseimage:v1
[
{
"Id": "sha256:478aa764196f9af929ad78385e0a8a7dfdbbfc35d0b97f4006a1d668a976972c",
"RepoTags": [
"sn0716/parseimage:v1",
"sn0716/parseimage:v3"
],
"RepoDigests": [],
"Parent": "",
"Comment": "buildkit.dockerfile.v0",
"Created": "2023-08-15T11:05:07.950123001Z",
"Container": "",
"ContainerConfig": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": null,
"Cmd": null,
"Image": "",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": null
},
"DockerVersion": "",
"Author": "",
"Config": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh"
],
"Image": "",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": null
},
"Architecture": "arm64",
"Variant": "v8",
"Os": "linux",
"Size": 5294882,
"VirtualSize": 5294882,
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/0dac0aeb6e223b13a1c8c2b1f26bf24c327f51ec2309efcc05859765763c8ceb/diff",
"MergedDir": "/var/lib/docker/overlay2/0oxfe6onylg7fgiiw4d9kyetp/merged",
"UpperDir": "/var/lib/docker/overlay2/0oxfe6onylg7fgiiw4d9kyetp/diff",
"WorkDir": "/var/lib/docker/overlay2/0oxfe6onylg7fgiiw4d9kyetp/work"
},
"Name": "overlay2"
},
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:17bec77d7fdc6988cd96b3051b4ad4d3cd6031b2faf0581468be64aac0acc20b",
"sha256:d45db941eac9b1f5764ee8fec34615a4a27f064547a9c5b9389b54df525ec6be"
]
},
"Metadata": {
"LastTagTime": "2024-07-02T08:45:10.626128273Z"
}
}
]
위의 GraphDriver 라는 항목에 Overlay Filesystem 구조인 LowerDir, MergedDir, UpperDir, WorkDir 경로나 지정돼있다.
해당 경로에 직접 들어가 확인을 해보면 이해가 쉽다.
* MergedDir 경로는 해당 이미지가 실제 실행되고 있지 않으면 마운트되지 않아서 해당 경로가 존재하지 않는다. 신기하게 해당 이미지를 실제로 실행시키고 나면 MergedDir 경로에 많은 것들이 마운트 되는 것을 확인했다.
RootFS의 Layers 에는 여러가지 레이어 정보가 존재한다. 실제 레이어의 갯수만큼 정보가 노출되며 위의 경우 2가지 레이어가 존재한다.
위와 같이 이미지를 pull 받을 때 아래 어떤 레이어들이 pull 받아졌는지 정보가 나온다.
컨테이너 이미지의 구조
컨테이너가 실행될 때 활용되는 파일시스템인 OverlayFS의 원리에 대해서는 대충 이해가 됐다.
그렇다면 이제 이미지 구조를 까보자! 어떻게 이러한 겹겹의 레이어들을 효율적으로 주고 받을 수 있을까?
alpine 이미지를 베이스 이미지로 하는 매우 간단한 이미지를 만들어보자.
snchoi@snchoi:~/overlayfs-example$ cat Dockerfile
FROM alpine:3.16.3
RUN echo "Hello Nyeong" > /tmp/test1
Dockerfile을 간단히 작성하고 빌드를 진행했다.
snchoi@snchoi:~/overlayfs-example$ sudo docker build -t sn0716/parseimage:v1 .
[+] Building 3.5s (6/6) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 94B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/alpine:3.16.3 2.6s
=> [1/2] FROM docker.io/library/alpine:3.16.3@sha256:b95359c2505145f16c6aa384f9cc74eeff78eb36d308ca4fd902eeeb0a0b161b 0.5s
=> => resolve docker.io/library/alpine:3.16.3@sha256:b95359c2505145f16c6aa384f9cc74eeff78eb36d308ca4fd902eeeb0a0b161b 0.0s
=> => sha256:b95359c2505145f16c6aa384f9cc74eeff78eb36d308ca4fd902eeeb0a0b161b 1.64kB / 1.64kB 0.0s
=> => sha256:559254f7ee68d88649077bd0cc6dfb94c337aadb8411d0fe5eae3b037578ec13 528B / 528B 0.0s
=> => sha256:2b4661558fb8cf1ec295ccd9c6d1cd42067ef517b0e538c9de65f733a8e3dd7e 1.49kB / 1.49kB 0.0s
=> => sha256:6875df1f535433e5affe18ecfde9acb7950ab5f76887980ff06c5cdd48cf98f4 2.71MB / 2.71MB 0.3s
=> => extracting sha256:6875df1f535433e5affe18ecfde9acb7950ab5f76887980ff06c5cdd48cf98f4 0.1s
=> [2/2] RUN echo "Hello Nyeong" > /tmp/test1 0.3s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:478aa764196f9af929ad78385e0a8a7dfdbbfc35d0b97f4006a1d668a976972c 0.0s
=> => naming to docker.io/sn0716/parseimage:v1 0.0s
snchoi@snchoi:~/overlayfs-example$ sudo docker save -o parseimage.tar sn0716/parseimage:v1
docker save 명령으로 이미지를 tar 파일로 떨구었다.
snchoi@snchoi:~/overlayfs-example$ mkdir contents
snchoi@snchoi:~/overlayfs-example$ sudo tar xf parseimage.tar -C ./contents
tar로 생성된 이미지를 풀어서 tree로 확인해보자.
압축을 풀어보니, 위와 같은 구조가 보인다. 다음과 같이 구조화할 수 있다. 공식문서가 존재하니 확인해보자!
https://github.com/opencontainers/image-spec/blob/main/image-layout.md
- manifest.json: 최상위 이미지에 관한 정보
- [sha256해시].json: Image JSON file 이라고 불리고, 이미지와 관련한 메타데이터들이 들어있다.
- repository: 이미지의 이름 및 태그와 관련한 메타데이터가 기록된다.
- layer 디렉터리: 각각의 디렉터리들이 레이어를 의미한다.
- VERSION: json file에 관한 스키마 버전
- json: 레이어에 관한 메타데이터
- layer.tar: 파일시스템 변경사항이 반영된 tar 파일. 실질적인 layer
snchoi@snchoi:~/overlayfs-example/contents$ cat manifest.json | jq .
[
{
"Config": "478aa764196f9af929ad78385e0a8a7dfdbbfc35d0b97f4006a1d668a976972c.json",
"RepoTags": [
"sn0716/parseimage:v1"
],
"Layers": [
"72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556/layer.tar",
"896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d/layer.tar"
]
}
]
snchoi@snchoi:~/overlayfs-example/contents$ cat 478aa764196f9af929ad78385e0a8a7dfdbbfc35d0b97f4006a1d668a976972c.json | jq .
{
"architecture": "arm64",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh"
],
"OnBuild": null
},
"created": "2023-08-15T11:05:07.950123001Z",
"history": [
{
"created": "2022-11-12T03:39:38.442492606Z",
"created_by": "/bin/sh -c #(nop) ADD file:57d621536158358b14d15155826ef2dd4ca034278044111ec0aaf6717016e569 in / "
},
{
"created": "2022-11-12T03:39:38.551417892Z",
"created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]",
"empty_layer": true
},
{
"created": "2023-08-15T11:05:07.950123001Z",
"created_by": "RUN /bin/sh -c echo \"Hello Nyeong\" > /tmp/test1 # buildkit",
"comment": "buildkit.dockerfile.v0"
}
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:17bec77d7fdc6988cd96b3051b4ad4d3cd6031b2faf0581468be64aac0acc20b",
"sha256:d45db941eac9b1f5764ee8fec34615a4a27f064547a9c5b9389b54df525ec6be"
]
},
"variant": "v8"
}
snchoi@snchoi:~/overlayfs-example/contents$ cat repositories | jq .
{
"sn0716/parseimage": {
"v1": "896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d"
}
}
snchoi@snchoi:~/overlayfs-example/contents$ cat 72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556/json | jq .
{
"id": "72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556",
"created": "1970-01-01T00:00:00Z",
"container_config": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": null,
"Cmd": null,
"Image": "",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": null
},
"os": "linux"
}
snchoi@snchoi:~/overlayfs-example/contents$ cat 896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d/json | jq .
{
"id": "896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d",
"parent": "72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556",
"created": "2023-08-15T11:05:07.950123001Z",
"container_config": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": null,
"Cmd": null,
"Image": "",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": null
},
"config": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh"
],
"Image": "",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": null
},
"architecture": "arm64",
"variant": "v8",
"os": "linux"
}
sha256sum 명령어를 통해 각 layer 폴더의 layer.tar의 해시값을 확인해보면
snchoi@snchoi:~/overlayfs-example/contents$ find ./ -name layer.tar -exec sha256sum {} \;
17bec77d7fdc6988cd96b3051b4ad4d3cd6031b2faf0581468be64aac0acc20b ./72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556/layer.tar
d45db941eac9b1f5764ee8fec34615a4a27f064547a9c5b9389b54df525ec6be ./896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d/layer.tar
Image Json file의 diff_ids가 위에서 확인한 해시값과 같은 것을 확인할 수 있다. diff_ids 인덱스 순서가 layer의 순서와 같다고 한다. 또한 각 layer 폴더의 json 파일의 내용을 통해서 상위 layer에 대한 정보도 알 수 있다.
layer.tar를 풀어서 어떤 파일들이 있는지 확인해보면 RUN echo "Hello Nyeong" > /tmp/test1 구문으로 추가된 레이러를 확인할 수 있다.
snchoi@snchoi:~/overlayfs-example$ mkdir layerdump
snchoi@snchoi:~/overlayfs-example$ tar xf contents/896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d/layer.tar -C layerdump/
snchoi@snchoi:~/overlayfs-example/layerdump$ cd tmp/
snchoi@snchoi:~/overlayfs-example/layerdump/tmp$ ls
test1
snchoi@snchoi:~/overlayfs-example/layerdump/tmp$ cat test1
Hello Nyeong
이제 컨테이너 구조의 규칙을 알았으니, 툴 없이 이미지에 레이어를 추가해볼 수 있다.
snchoi@snchoi:~/overlayfs-example/contents$ mkdir -p newlayer/tmp/
snchoi@snchoi:~/overlayfs-example/contents$ echo "Did it work?" > newlayer/tmp/test2
snchoi@snchoi:~/overlayfs-example/contents$ cd newlayer/ && tar cf ../layer.tar . && rm -rf newlayer
snchoi@snchoi:~/overlayfs-example/contents$ ls
478aa764196f9af929ad78385e0a8a7dfdbbfc35d0b97f4006a1d668a976972c.json layer.tar
72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556 manifest.json
896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d repositories
snchoi@snchoi:~/overlayfs-example/contents$ sha256sum layer.tar
facb9dbc0daf07dd9edd02d1a9d2e7717996c5ae6e07e625a376ae2c7bc309fc layer.tar
확인한 해시값 이름으로 폴더를 만들고(폴더 이름도 나름의 규칙에 의해 생성된다고 하지만, 여기서는 패스!), VERSION 파일에는 1.0이라는 값을 넣어주고, json 파일은 id, parent 필드만 규격대로 작성해주고 나머지는 구색만 갖추어 본다.
{
"id": "폴더명",
"parent": "마지막 레이어의 폴더명",
"created": "2023-04-10T16:19:28.050827879Z",
"container_config": {},
"config": {},
"architecture": "arm64",
"variant": "v8",
"os": "linux"
}
repositories, manifest.json, [sha256해시].json 파일도 아래와 같이 수정해주었다.
snchoi@snchoi:~/overlayfs-example/contents$ cat repositories | jq .
{
"sn0716/parseimage": {
"v2": "facb9dbc0daf07dd9edd02d1a9d2e7717996c5ae6e07e625a376ae2c7bc309fc"
}
}
snchoi@snchoi:~/overlayfs-example/contents$ cat manifest.json | jq .
[
{
"Config": "478aa764196f9af929ad78385e0a8a7dfdbbfc35d0b97f4006a1d668a976972c.json",
"RepoTags": [
"sn0716/parseimage:v2"
],
"Layers": [
"72528bc5c69b9f4f8dd8d685d1ffa658a69a9eb815aeea6f9f54a3a070b7b556/layer.tar",
"896bc5695f506c242c6efc442feb0a362bbb1474a912fca88a9bb95aa341a09d/layer.tar",
"facb9dbc0daf07dd9edd02d1a9d2e7717996c5ae6e07e625a376ae2c7bc309fc/layer.tar"
]
}
]
snchoi@snchoi:~/overlayfs-example/contents$ cat 478aa764196f9af929ad78385e0a8a7dfdbbfc35d0b97f4006a1d668a976972c.json | jq .
{
"architecture": "arm64",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh"
],
"OnBuild": null
},
"created": "2023-08-15T11:05:07.950123001Z",
"history": [
{
"created": "2022-11-12T03:39:38.442492606Z",
"created_by": "/bin/sh -c #(nop) ADD file:57d621536158358b14d15155826ef2dd4ca034278044111ec0aaf6717016e569 in / "
},
{
"created": "2022-11-12T03:39:38.551417892Z",
"created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]",
"empty_layer": true
},
{
"created": "2023-08-15T11:05:07.950123001Z",
"created_by": "RUN /bin/sh -c echo \"Hello Nyeong\" > /tmp/test1 # buildkit",
"comment": "buildkit.dockerfile.v0"
}
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:17bec77d7fdc6988cd96b3051b4ad4d3cd6031b2faf0581468be64aac0acc20b",
"sha256:d45db941eac9b1f5764ee8fec34615a4a27f064547a9c5b9389b54df525ec6be",
"sha256:facb9dbc0daf07dd9edd02d1a9d2e7717996c5ae6e07e625a376ae2c7bc309fc"
]
},
"variant": "v8"
}
최종적으로 다른 레이어와 완전히 같은 모양이 되었다.
이제 전체 폴더의 내용을 tar로 묶고, docker load 명령어를 사용하여 새로 추가된 레이어가 잘 반영되었는지 확인해보자.
snchoi@snchoi:~/overlayfs-example/contents$ tar cf ../parseimage2.tar .
snchoi@snchoi:~/overlayfs-example/contents$ cd ..
snchoi@snchoi:~/overlayfs-example$ ls
contents Dockerfile layerdump parseimage2.tar parseimage.tar
snchoi@snchoi:~/overlayfs-example$ file parseimage2.tar
parseimage2.tar: POSIX tar archive (GNU)
snchoi@snchoi:~/overlayfs-example$ sudo docker load < parseimage2.tar
Loaded image: sn0716/parseimage:v2
snchoi@snchoi:~/overlayfs-example$ sudo docker run -it sn0716/parseimage:v2
/ # exit
snchoi@snchoi:~/overlayfs-example$ sudo docker run -it sn0716/parseimage:v2 cat /tmp/test2
Did it work?
도구 하나 없이 새로운 컨테이너 이미지까지 만들어 보는것에 성공했다!
'리눅스(Linux) > 컨테이너(Container)' 카테고리의 다른 글
VM과 컨테이너 (0) | 2023.07.29 |
---|---|
리눅스의 컨테이너 격리 (cgroups, namespace) (1) | 2023.07.28 |
[Docker] docker inspect (0) | 2021.09.11 |
[도커] 스웜모드 & 스웜모드의서비스 장애 복구 & 서비스 컨테이너에 설정 정보 전달(secret,config) & 도커 스웜 네트워크 & 서비스 디스커버리 (0) | 2020.09.19 |
[도커] 쉘스크립트에 도커 명령어 작성 & Docker compose & swarm (0) | 2020.09.16 |