[{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/container/","section":"tags","summary":"","title":"Container"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/","section":"Digital Garden","summary":"","title":"Digital Garden"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/lima/","section":"tags","summary":"","title":"Lima"},{"content":"Lima의 장점중 하나는 컨테이너 런타임 환경을 탬플릿으로 제공해준다는 것이다.\n이말은 내가 생성할 vm의 탬플릿을 지정하면 containerd, runc, nerdctl이 모두 설치가 된다는 의미다\n이 글은 Lima가 vm에 설치되는 containerd를 어떻게 관리하는지 코드 레벨에서 확인하고, 실제로 버전을 바꾸는 방법을 공유한다.\nLima의 컨테이너런타임 설치 구조 #Lima는 Containerd, runc, Buildkit을 개별적으로 설치하지 않고 nerdctl-full 이라는 번들 패키지를 통해 한꺼번에 설치한다.\n$ limactl info | jq .defaultTemplate.containerd.archives [ { \u0026#34;location\u0026#34;: \u0026#34;https://github.com/containerd/nerdctl/releases/download/v2.3.3/nerdctl-full-2.3.3-linux-amd64.tar.gz\u0026#34;, \u0026#34;arch\u0026#34;: \u0026#34;x86_64\u0026#34;, \u0026#34;digest\u0026#34;: \u0026#34;sha256:8d39a120d8414e3aff15ac05accf51bbbad6baf54764ae709b09087e4544c1ad\u0026#34; }, { \u0026#34;location\u0026#34;: \u0026#34;https://github.com/containerd/nerdctl/releases/download/v2.3.3/nerdctl-full-2.3.3-linux-arm64.tar.gz\u0026#34;, \u0026#34;arch\u0026#34;: \u0026#34;aarch64\u0026#34;, \u0026#34;digest\u0026#34;: \u0026#34;sha256:2322f29f451189dd790b5d7c599b4600c210ff0f2c10244308a8e6a024274066\u0026#34; } ] 바이너리에 내장된 기본 버전은 해당 명령어를 통해 확인할 수 있다.\n이 값은 프로젝트의 pkg/limayaml/containerd.yaml 에 저장되어 있고 go:embed를 통해 빌드 시에 바이너리에 구워진다. 즉 Lima 바이너리를 업그레이드하지 않으면 기본값은 바뀌지 않는다.\n// pkg/limayaml/defaults.go //go:embed containerd.yaml var defaultContainerdYAML []byte func defaultContainerdArchives() []limatype.File { var containerd ContainerdYAML yaml.Unmarshal(defaultContainerdYAML, \u0026amp;containerd) return containerd.Archives } 우선순위 #// pkg/limayaml/defaults.go:546-548 y.Containerd.Archives = slices.Concat(o.Containerd.Archives, y.Containerd.Archives, d.Containerd.Archives) if len(y.Containerd.Archives) == 0 { y.Containerd.Archives = defaultContainerdArchives() } Lima의 설정 정보는 ~/.lima/{vm}/lima.yaml 에 저장된다\ncontainerd.archives이 주석되어 읽어오는 값이 없으면 바이너리 내장 기본값을 사용하게 된다\ncontainerd: # Enable system-wide (aka rootful) containerd and its dependencies (BuildKit, Stargz Snapshotter) # Note that `nerdctl.lima` only works in rootless mode; you have to use `lima sudo nerdctl ...` # to use rootful containerd with nerdctl. # 🟢 Builtin default: false system: null # Enable user-scoped (aka rootless) containerd and its dependencies # 🟢 Builtin default: true (for x86_64 and aarch64) user: null # # Override containerd archive # # 🟢 Builtin default: hard-coded URL with hard-coded digest (see the output of `limactl info | jq .defaultTemplate.containerd.archives`) # archives: # - location: \u0026#34;https://github.com/containerd/nerdctl/releases/download/v2.3.3/nerdctl-full-2.3.3-linux-amd64.tar.gz\u0026#34; # arch: \u0026#34;x86_64\u0026#34; # digest: \u0026#34;sha256:8d39a120d8414e3aff15ac05accf51bbbad6baf54764ae709b09087e4544c1ad\u0026#34; # - location: \u0026#34;https://github.com/containerd/nerdctl/releases/download/v2.3.3/nerdctl-full-2.3.3-linux-arm64.tar.gz\u0026#34; # arch: \u0026#34;aarch64\u0026#34; # digest: \u0026#34;sha256:2322f29f451189dd790b5d7c599b4600c210ff0f2c10244308a8e6a024274066\u0026#34; # 오픈소스의 재미는 원한다면 내부동작을 다 살펴볼 수 있다는 것이다\nVM에 번들 패키지를 전달하기 #Lima는 VM 부팅에 필요한 스크립트와 아카이브를 ISO 9660 포맷의 가상 CD-ROM으로 만들어서 VM에 전달한다.\n운영체제 이미지 파일은 .iso 와 같은 포맷이지만 용도가 다름 ISO 안에 들어가는 것들:\nboot/*.sh — provisioning 스크립트 (containerd 설치 등) nerdctl-full.tgz — nerdctl 아카이브 user-data, meta-data — cloud-init 설정 VM이 부팅되면 cloud-init이 ISO를 마운트하고 스크립트를 실행한다.\nISO는 매번 새로 만들어지기 때문에, lima.yaml의 archives를 바꾸고 재시작하면 새 아카이브가 ISO에 반영된다.\n설치 스크립트의 업그레이드 판단 방식 #pkg/cidata/cidata.TEMPLATE.d/boot.Linux/40-install-containerd.sh:\ntmp_extract_nerdctl=\u0026#34;$(mktemp -d)\u0026#34; tar Cxaf \u0026#34;${tmp_extract_nerdctl}\u0026#34; \u0026#34;${LIMA_CIDATA_MNT}\u0026#34;/\u0026#34;${LIMA_CIDATA_CONTAINERD_ARCHIVE}\u0026#34; bin/nerdctl if [ ! -f \u0026#34;${LIMA_CIDATA_GUEST_INSTALL_PREFIX}\u0026#34;/bin/nerdctl ] || \\ [[ \u0026#34;${tmp_extract_nerdctl}\u0026#34;/bin/nerdctl -nt \u0026#34;${LIMA_CIDATA_GUEST_INSTALL_PREFIX}\u0026#34;/bin/nerdctl ]]; then # 설치 진행 tar Cxaf \u0026#34;${LIMA_CIDATA_GUEST_INSTALL_PREFIX}\u0026#34; \u0026#34;${LIMA_CIDATA_MNT}\u0026#34;/\u0026#34;${LIMA_CIDATA_CONTAINERD_ARCHIVE}\u0026#34; fi ! -f : nerdctl이 없으면 설치 -nt (newer than) : ISO의 nerdctl 바이너리가 설치된 것보다 파일 수정 시각이 최신이면 설치 버전 숫자가 아니라 파일 타임스탬프로 비교한다.\n따라서 다운그레이드를 실행하고 싶다면 vm에 설치된 nerdctl를 제거하면 된다\n버전 변경 방법 #방법 1: lima.yaml에 archives 직접 명시 (가장 간단) ## lima.yaml containerd: system: false user: true archives: - location: \u0026#34;https://github.com/containerd/nerdctl/releases/download/v2.3.3/nerdctl-full-2.3.3-linux-amd64.tar.gz\u0026#34; arch: \u0026#34;x86_64\u0026#34; digest: \u0026#34;sha256:...\u0026#34; - location: \u0026#34;https://github.com/containerd/nerdctl/releases/download/v2.3.3/nerdctl-full-2.3.3-linux-arm64.tar.gz\u0026#34; arch: \u0026#34;aarch64\u0026#34; digest: \u0026#34;sha256:...\u0026#34; lima.yaml에서 archives에 버전을 수정 후 재기동한다.\nlimactl stop \u0026lt;instance\u0026gt; \u0026amp;\u0026amp; limactl start \u0026lt;instance\u0026gt; 시작 로그에서 Upgrading existing nerdctl 메시지가 보이면 성공.\n단, 같은 URL을 이미 받은 적 있으면 ~/Library/Caches/lima/의 캐시를 재사용하므로 재다운로드 없이 바로 설치된다.\n방법 2: 소스 빌드 시 기본값 변경 #pkg/limayaml/containerd.yaml을 수정하고 빌드:\n# containerd.yaml 수정 후 make ./bin/limactl info | jq \u0026#39;.defaultTemplate.containerd.archives\u0026#39; # 확인 이 방법은 archives를 명시하지 않은 모든 인스턴스의 기본값을 바꾼다.\n","date":"2026-06-27","permalink":"https://blog.opjt.dev/posts/15/","section":"posts","summary":"\u003cp\u003eLima의 장점중 하나는 컨테이너 런타임 환경을 탬플릿으로 제공해준다는 것이다.\u003cbr\u003e\n이말은 내가 생성할 vm의 탬플릿을 지정하면 \u003ccode\u003econtainerd\u003c/code\u003e, \u003ccode\u003erunc\u003c/code\u003e, \u003ccode\u003enerdctl\u003c/code\u003e이 모두 설치가 된다는 의미다\u003c/p\u003e","title":"Lima VM의 containerd 버전 변경하기"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/posts/","section":"posts","summary":"","title":"posts"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/","section":"tags","summary":"","title":"tags"},{"content":"전편 격리만으로는 부족하다에서 cgroup, seccomp, capability로 컨테이너의 보안을 강화했다. 이것으로 얼추 rootless 컨테이너 런타임의 핵심 구현은 끝났다.\n하지만 한 가지 불편한점이 있는데. 매번 컨테이너를 실행하려면 rootfs를 직접 준비해야 한다.\n지금까지는 docker export로 기존 컨테이너의 파일시스템을 뽑아서 사용했다.\n컨테이너를 만들기 위해 Docker에 의존하고 있는 셈이다. 물론 alpine 같은 배포판은 공식 사이트에서 mini rootfs tar를 받을 수도 있지만, 이미지마다 찾아서 수동으로 받아야 한다는 불편함은 마찬가지다.\n이번 편에서는 docker pull이 실제로 무엇을 하는지 들여다보고, OCI 표준을 따라 Docker Hub에서 직접 이미지를 가져오는 기능을 구현한다.\nOCI 표준 #OCI란 #OCI (Open Container Initiative)는 컨테이너 관련 표준을 정의하는 프로젝트다.\nDocker가 컨테이너 생태계를 사실상 독점하던 시절, 이미지 포맷과 런타임이 Docker라는 하나의 벤더에 종속되어 있었다. 이 문제를 해결하기 위해 Docker, Google, Red Hat 등이 모여 OCI를 설립했고, 컨테이너의 핵심 요소들을 개방형 표준으로 정의했다.\nOCI는 세 가지 핵심 스펙을 정의한다:\nOCI Runtime Spec — 컨테이너를 어떻게 실행할 것인가. runc가 대표적인 구현체다 OCI Image Spec — 컨테이너 이미지의 구조. manifest, config, layer의 포맷을 정의한다 OCI Distribution Spec — 레지스트리와 이미지를 주고받는 HTTP API를 정의한다 덕분에 Docker Hub에 올린 이미지를 Podman으로 실행할 수 있고, GitHub Container Registry의 이미지를 Docker로 가져올 수 있다. 모두 같은 표준을 따르기 때문이다.\n이번 글에서는 Image Spec과 Distribution Spec을 다룬다. 이미지가 어떤 구조로 되어 있는지, 레지스트리에서 어떻게 가져오는지에 대해 알아보자\n이미지의 구조 #컨테이너 이미지는 하나의 큰 파일이 아니다. 레이어(layer)들의 묶음이다.\n예를 들어 alpine 이미지는 base layer 하나로 구성되지만, 일반적인 애플리케이션 이미지는 여러 레이어가 쌓여있다:\nLayer 1: alpine base (기본 파일시스템) Layer 2: RUN apk add curl (패키지 설치) Layer 3: COPY app /app (애플리케이션 코드) Dockerfile의 각 명령이 하나의 레이어가 된다. 레이어는 이전 레이어 위의 변경분만 담고 있다. 그래서 base image가 같은 여러 이미지가 있을 때, Layer 1은 한 번만 다운로드하면 나머지 이미지에서 재사용할 수 있다. docker pull이 \u0026ldquo;Already exists\u0026quot;를 출력하며 건너뛰는 레이어가 바로 이것이다.\n이 레이어들을 관리하는 구조는 세 단계로 되어 있다:\nManifest Index (Fat Manifest) ├── Manifest (linux/amd64) │ ├── Layer 1 (sha256:abc...) │ ├── Layer 2 (sha256:def...) │ └── Config (sha256:789...) ├── Manifest (linux/arm64) │ ├── Layer 1 (sha256:111...) │ ├── Layer 2 (sha256:222...) │ └── Config (sha256:333...) └── Manifest (linux/s390x) └── ... Manifest Index — 최상위 목록이다. alpine:latest라는 하나의 태그 뒤에 arm64, amd64 등 여러 플랫폼의 manifest가 존재한다. 같은 이미지 이름이라도 아키텍처에 따라 실제 내용이 다르기 때문에, 이 인덱스가 플랫폼별 manifest를 가리키는 역할을 한다 Manifest — 특정 플랫폼용 이미지의 실제 구성 정보다. 어떤 레이어들로 이루어져 있는지, config는 무엇인지를 담고 있다 Layer — 파일시스템의 변경분이다. gzip으로 압축된 tar 아카이브 형태로 저장된다 OCI 스펙에서 각 요소의 종류는 mediaType으로 구분한다. HTTP의 Content-Type과 같은 개념이다:\n요소 mediaType Manifest Index application/vnd.oci.image.index.v1+json Manifest application/vnd.oci.image.manifest.v1+json Layer application/vnd.oci.image.layer.v1.tar+gzip 그리고 모든 요소는 digest (sha256:...)로 식별된다. digest는 해당 내용의 SHA256 해시값이다.\n내용이 같으면 반드시 같은 digest를 갖고, 내용이 다르면 반드시 다른 digest를 갖는다.\n이를 content-addressable이라 한다. 파일 이름이나 경로가 아니라 내용 자체가 주소가 되는 방식이다.\n이 덕분에 레이어 중복 제거가 자연스럽게 이루어진다. 두 이미지가 같은 base layer를 쓰면 digest가 같으므로, 한 번만 저장하면 된다.\nDocker Registry API #OCI Distribution Spec을 기반으로 Docker Hub에서 이미지를 가져오는 과정을 살펴보자.\n전체 흐름 #nootainer pull alpine │ ▼ GET auth.docker.io/token ── 인증 토큰 획득 │ ▼ GET /v2/library/alpine/manifests/latest ── Manifest Index 조회 │ ▼ linux/arm64 manifest의 digest 추출 │ ▼ GET /v2/library/alpine/manifests/{digest} ── Manifest 조회 │ ▼ 레이어 목록 추출 │ ▼ GET /v2/library/alpine/blobs/{digest} ── 각 레이어 다운로드 │ ▼ gzip 해제 → tar 추출 → rootfs_alpine/ 크게 세 종류의 API를 사용한다: 인증, manifest 조회, blob 다운로드. 하나씩 살펴보자.\n인증 #Docker Hub는 공개 이미지라도 인증 토큰을 요구한다. 로그인이 필요한 건 아니고, 익명으로 토큰을 발급받는 방식이다.\ncurl -s \u0026#34;https://auth.docker.io/token?service=registry.docker.io\u0026amp;scope=repository:library/alpine:pull\u0026#34; | jq .token -r scope에 어떤 레포지토리에 대해 어떤 권한(pull)을 요청하는지를 명시한다. 실행하면 긴 JWT 토큰 문자열이 반환된다. 이후 모든 레지스트리 API 요청에 Authorization: Bearer \u0026lt;token\u0026gt; 헤더를 붙여서 요청하면 된다.\nManifest Index 조회 #발급받은 토큰으로 manifest를 요청해보자:\nTOKEN=$(curl -s \u0026#34;https://auth.docker.io/token?service=registry.docker.io\u0026amp;scope=repository:library/alpine:pull\u0026#34; | jq .token -r) curl -s \u0026#34;https://registry-1.docker.io/v2/library/alpine/manifests/latest\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ -H \u0026#34;Accept: application/vnd.oci.image.index.v1+json\u0026#34; | jq . 응답으로 Manifest Index가 돌아온다:\n{ \u0026#34;manifests\u0026#34;: [ { \u0026#34;digest\u0026#34;: \u0026#34;sha256:abc...\u0026#34;, \u0026#34;platform\u0026#34;: { \u0026#34;architecture\u0026#34;: \u0026#34;amd64\u0026#34;, \u0026#34;os\u0026#34;: \u0026#34;linux\u0026#34; } }, { \u0026#34;digest\u0026#34;: \u0026#34;sha256:def...\u0026#34;, \u0026#34;platform\u0026#34;: { \u0026#34;architecture\u0026#34;: \u0026#34;arm64\u0026#34;, \u0026#34;os\u0026#34;: \u0026#34;linux\u0026#34; } }, { \u0026#34;digest\u0026#34;: \u0026#34;sha256:ghi...\u0026#34;, \u0026#34;platform\u0026#34;: { \u0026#34;architecture\u0026#34;: \u0026#34;unknown\u0026#34; } } ] } 여기서 살펴볼 점은 architecture가 unknown인 manifest가 있다는 것이다.\n이것은 attestation manifest로, 실제 컨테이너 이미지가 아니라 이미지의 보안 메타데이터를 담고 있다.\n{ \u0026#34;annotations\u0026#34;: { \u0026#34;com.docker.official-images.bashbrew.arch\u0026#34;: \u0026#34;amd64\u0026#34;, \u0026#34;vnd.docker.reference.digest\u0026#34;: \u0026#34;sha256:59855d3dceb3ae53991193bd03301e082b2a7faa56a514b03527ae0ec2ce3a95\u0026#34;, \u0026#34;vnd.docker.reference.type\u0026#34;: \u0026#34;attestation-manifest\u0026#34; }, \u0026#34;digest\u0026#34;: \u0026#34;sha256:fe2385f276937dcf780967a5385767fd34b34580c8ed8d303a0cd1485a692635\u0026#34;, \u0026#34;mediaType\u0026#34;: \u0026#34;application/vnd.oci.image.manifest.v1+json\u0026#34;, \u0026#34;platform\u0026#34;: { \u0026#34;architecture\u0026#34;: \u0026#34;unknown\u0026#34;, \u0026#34;os\u0026#34;: \u0026#34;unknown\u0026#34; }, \u0026#34;size\u0026#34;: 838 }, 이 안에는 SBOM (Software Bill of Materials) 이 포함되어 있는데,\nSBOM은 말 그대로 소프트웨어의 부품 목록으로 이 이미지에 어떤 패키지가 들어있는지, 어떤 버전인지, 어떻게 빌드되었는지를 기록한 명세서다.\n특정 라이브러리에 보안 취약점이 발견되었을 때, SBOM이 있으면 어떤 이미지가 영향을 받는지 바로 파악할 수 있다.\ndocker pull을 하면 단순히 파일시스템만 가져오는 것이 아닌, 그 안에 공급망 보안을 위한 데이터까지 함께 배포되고 있었다.\n현재 구현에서는 linux/{GOARCH}에 매칭되는 manifest만 찾으므로, 이 attestation manifest는 자연스럽게 건너뛰게 된다.\nManifest 조회 #Manifest Index에서 현재 아키텍처(예: arm64)에 맞는 digest를 찾았다면, 그 digest로 다시 요청한다:\nDIGEST=\u0026#34;sha256:59855d3dceb3ae53991193bd03301e082b2a7faa56a514b03527ae0ec2ce3a95\u0026#34; # Manifest Index에서 찾은 arm64 digest curl -s \u0026#34;https://registry-1.docker.io/v2/library/alpine/manifests/$DIGEST\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ -H \u0026#34;Accept: application/vnd.oci.image.manifest.v1+json\u0026#34; | jq . 응답으로 해당 플랫폼의 Manifest를 받는다. 여기에 아래와 같은 레이어 목록이 들어있다\n{ \u0026#34;schemaVersion\u0026#34;: 2, \u0026#34;mediaType\u0026#34;: \u0026#34;application/vnd.oci.image.manifest.v1+json\u0026#34;, \u0026#34;config\u0026#34;: { \u0026#34;mediaType\u0026#34;: \u0026#34;application/vnd.oci.image.config.v1+json\u0026#34;, \u0026#34;digest\u0026#34;: \u0026#34;sha256:a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb\u0026#34;, \u0026#34;size\u0026#34;: 611 }, \u0026#34;layers\u0026#34;: [ { \u0026#34;mediaType\u0026#34;: \u0026#34;application/vnd.oci.image.layer.v1.tar+gzip\u0026#34;, \u0026#34;digest\u0026#34;: \u0026#34;sha256:589002ba0eaed121a1dbf42f6648f29e5be55d5c8a6ee0f8eaa0285cc21ac153\u0026#34;, \u0026#34;size\u0026#34;: 3861821 } ], \u0026#34;annotations\u0026#34;: { \u0026#34;com.docker.official-images.bashbrew.arch\u0026#34;: \u0026#34;amd64\u0026#34;, \u0026#34;org.opencontainers.image.base.name\u0026#34;: \u0026#34;scratch\u0026#34;, \u0026#34;org.opencontainers.image.created\u0026#34;: \u0026#34;2026-01-28T01:18:02Z\u0026#34;, \u0026#34;org.opencontainers.image.revision\u0026#34;: \u0026#34;a037d70ba44f91b00dff940019d29a28f7ba1265\u0026#34;, \u0026#34;org.opencontainers.image.source\u0026#34;: \u0026#34;https://github.com/alpinelinux/docker-alpine.git#a037d70ba44f91b00dff940019d29a28f7ba1265:x86_64\u0026#34;, \u0026#34;org.opencontainers.image.url\u0026#34;: \u0026#34;https://hub.docker.com/_/alpine\u0026#34;, \u0026#34;org.opencontainers.image.version\u0026#34;: \u0026#34;3.23.3\u0026#34; } } alpine은 경량 이미지라 레이어가 하나뿐이지만, 일반적인 이미지는 여러 레이어가 나열된다.\nBlob 다운로드 #각 레이어의 digest로 blob API를 호출하면 실제 데이터를 받을 수 있다:\nLAYER_DIGEST=\u0026#34;sha256:589002ba0eaed121a1dbf42f6648f29e5be55d5c8a6ee0f8eaa0285cc21ac153\u0026#34; # Manifest에서 찾은 레이어 digest curl -sL \u0026#34;https://registry-1.docker.io/v2/library/alpine/blobs/$LAYER_DIGEST\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ -o layer.tar.gz tar -tzf layer.tar.gz | head # bin/ # bin/arch # bin/ash # bin/base64 # bin/bbconfig # ... 응답은 gzip 압축된 tar 아카이브다. 이걸 풀면 해당 레이어의 파일시스템(rootfs)이 나온다. 구현 #이제 위 과정을 Go 코드로 구현해보자. registry.go 파일을 생성하여 작성한다.\n데이터 구조 #먼저 API 응답을 파싱할 구조체들을 정의한다. OCI 스펙의 구조를 그대로 반영하고 있다\nhttps://github.com/opencontainers/distribution-spec/blob/main/conformance/image.go 참고 const ( authURL = \u0026#34;https://auth.docker.io/token\u0026#34; registryURL = \u0026#34;https://registry-1.docker.io\u0026#34; ) // Manifest Index: 멀티 아키텍처 manifest 목록 type manifestIndex struct { Manifests []platformManifest `json:\u0026#34;manifests\u0026#34;` } type platformManifest struct { Digest string `json:\u0026#34;digest\u0026#34;` Platform platform `json:\u0026#34;platform\u0026#34;` } type platform struct { Architecture string `json:\u0026#34;architecture\u0026#34;` OS string `json:\u0026#34;os\u0026#34;` } // Manifest: 특정 플랫폼의 레이어 목록 type manifest struct { Layers []layer `json:\u0026#34;layers\u0026#34;` } type layer struct { MediaType string `json:\u0026#34;mediaType\u0026#34;` Digest string `json:\u0026#34;digest\u0026#34;` Size int64 `json:\u0026#34;size\u0026#34;` } 인증 #이미지를 요청하기 전에 Docker Hub의 토큰 엔드포인트에 요청하여 Bearer 토큰을 발급받는다\nfunc getAuthToken(image string) (string, error) { url := fmt.Sprintf(\u0026#34;%s?service=registry.docker.io\u0026amp;scope=repository:%s:pull\u0026#34;, authURL, image) resp, err := http.Get(url) if err != nil { return \u0026#34;\u0026#34;, fmt.Errorf(\u0026#34;token request failed: %w\u0026#34;, err) } defer resp.Body.Close() var token tokenResponse if err := json.NewDecoder(resp.Body).Decode(\u0026amp;token); err != nil { return \u0026#34;\u0026#34;, fmt.Errorf(\u0026#34;token decode failed: %w\u0026#34;, err) } return token.Token, nil } 이후 모든 레지스트리 요청에 이 토큰을 붙이는 헬퍼 함수를 두어 사용하도록 한다\nfunc registryG어et(url, token string) (*http.Response, error) { req, err := http.NewRequest(\u0026#34;GET\u0026#34;, url, nil) if err != nil { return nil, err } req.Header.Set(\u0026#34;Authorization\u0026#34;, \u0026#34;Bearer \u0026#34;+token) req.Header.Set(\u0026#34;Accept\u0026#34;, \u0026#34;application/vnd.oci.image.manifest.v1+json\u0026#34;) return http.DefaultClient.Do(req) } Accept 헤더에 OCI manifest의 mediaType을 지정한다. 만약 명시하지 않으면 레지스트리가 Docker v2 포맷으로 응답할 수 있다. Manifest 조회 #manifest 조회는 앞서 설명한 대로 3단계를 거친다\nfunc getManifest(repoName, tag, token string) (*manifest, error) { // 1단계: Manifest Index 가져오기 url := fmt.Sprintf(\u0026#34;%s/v2/%s/manifests/%s\u0026#34;, registryURL, repoName, tag) resp, err := registryGet(url, token) if err != nil { return nil, fmt.Errorf(\u0026#34;manifest index request failed: %w\u0026#34;, err) } defer resp.Body.Close() var index manifestIndex json.NewDecoder(resp.Body).Decode(\u0026amp;index) // 2단계: 현재 아키텍처에 맞는 digest 찾기 var digest string for _, m := range index.Manifests { if m.Platform.Architecture == runtime.GOARCH \u0026amp;\u0026amp; m.Platform.OS == \u0026#34;linux\u0026#34; { digest = m.Digest break } } if digest == \u0026#34;\u0026#34; { return nil, fmt.Errorf(\u0026#34;no linux/%s manifest found\u0026#34;, runtime.GOARCH) } // 3단계: 해당 플랫폼의 Manifest 가져오기 url = fmt.Sprintf(\u0026#34;%s/v2/%s/manifests/%s\u0026#34;, registryURL, repoName, digest) resp2, err := registryGet(url, token) if err != nil { return nil, fmt.Errorf(\u0026#34;manifest request failed: %w\u0026#34;, err) } defer resp2.Body.Close() var m manifest json.NewDecoder(resp2.Body).Decode(\u0026amp;m) return \u0026amp;m, nil } runtime.GOARCH는 현재 바이너리가 빌드된 아키텍처를 반환한다. arm64 머신에서 빌드하면 \u0026quot;arm64\u0026quot;, amd64에서 빌드하면 \u0026quot;amd64\u0026quot;가 된다. 하드코딩 없이 환경에 맞는 이미지를 자동으로 선택할 수 있다. 레이어 다운로드 및 추출 #각 레이어는 gzip 압축된 tar 아카이브다. HTTP 응답 스트림을 바로 gzip 해제하고, tar 엔트리를 순회하면서 파일을 추출한다:\nfunc downloadAndExtractLayer(repoName, digest, token, destDir string) error { url := fmt.Sprintf(\u0026#34;%s/v2/%s/blobs/%s\u0026#34;, registryURL, repoName, digest) req, err := http.NewRequest(\u0026#34;GET\u0026#34;, url, nil) if err != nil { return err } req.Header.Set(\u0026#34;Authorization\u0026#34;, \u0026#34;Bearer \u0026#34;+token) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() // HTTP 응답 \u0026gt; gzip 해제 \u0026gt; tar 읽기 (스트리밍) gr, _ := gzip.NewReader(resp.Body) defer gr.Close() tr := tar.NewReader(gr) for { hdr, err := tr.Next() if err == io.EOF { break } target := filepath.Join(destDir, hdr.Name) switch hdr.Typeflag { case tar.TypeDir: os.MkdirAll(target, os.FileMode(hdr.Mode)) case tar.TypeReg: os.MkdirAll(filepath.Dir(target), 0755) f, _ := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) io.Copy(f, tr) f.Close() case tar.TypeSymlink: os.Remove(target) os.Symlink(hdr.Linkname, target) case tar.TypeLink: os.Remove(target) os.Link(filepath.Join(destDir, hdr.Linkname), target) } } return nil } tar 아카이브에는 여러 종류의 파일이 들어있고, 각각 다르게 처리해야 한다:\n디렉토리 (TypeDir) — 경로를 생성한다 일반 파일 (TypeReg) — 파일을 생성하고 내용을 복사한다 심볼릭 링크 (TypeSymlink) — /lib/libc.so → libc.so.6 같은 링크를 만든다 하드 링크 (TypeLink) — 같은 파일 데이터를 가리키는 또 다른 이름을 만든다 레이어는 순서대로 같은 디렉토리에 추출된다. 나중 레이어의 파일이 이전 레이어의 파일을 덮어쓰면서 최종 rootfs가 완성된다. Dockerfile에서 아래쪽 명령이 위쪽 결과 위에 쌓이는 것과 같은 원리다.\n코드를 단순하게 유지하기 위해 이번 구현에서는 몇 가지 디테일을 생략했다\nWhiteout 파일: OCI 스펙에서 이전 레이어의 파일을 \u0026ldquo;삭제\u0026quot;할 때는 .wh. 접두사가 붙은 whiteout 파일을 사용한다. 예를 들어 Layer 1에 /app/config.json이 있고 Layer 2에서 이를 삭제하면, Layer 2의 tar 안에 .wh.config.json이라는 빈 파일이 들어있다. 현재 구현에서는 이를 처리하지 않아 whiteout 파일이 그대로 생성된다. 디바이스 노드: tar에는 /dev/null 같은 디바이스 노드(TypeChar, TypeBlock)도 포함될 수 있는데, rootless 환경에서는 mknod 권한이 없어 생성이 실패한다. 현재 코드가 기본 파일 타입만 처리하는 이유이기도 하다. 물리적 병합 vs OverlayFS: 지금은 직관적인 이해를 위해 모든 레이어를 하나의 디렉토리에 물리적으로 합치고 있다. 실제 런타임은 각 레이어를 독립된 디렉토리에 저장한 뒤, 컨테이너 실행 시 커널의 OverlayFS로 논리적으로 합쳐서 보여준다. (이렇게 해야 레이어 단위 캐싱과 재사용이 가능하기 때문) pull 플로우 #func pull(image, tag string) { repoName := \u0026#34;library/\u0026#34; + image token, err := getAuthToken(repoName) if err != nil { log.Fatal(\u0026#34;auth failed:\u0026#34;, err) } fmt.Println(\u0026#34;token acquired\u0026#34;) m, err := getManifest(repoName, tag, token) if err != nil { log.Fatal(\u0026#34;manifest failed:\u0026#34;, err) } fmt.Printf(\u0026#34;found %d layer(s)\\n\u0026#34;, len(m.Layers)) destDir := filepath.Join(\u0026#34;rootfs_\u0026#34; + image) os.MkdirAll(destDir, 0755) for i, l := range m.Layers { fmt.Printf(\u0026#34;downloading layer %d/%d: %s\\n\u0026#34;, i+1, len(m.Layers), l.Digest[:25]+\u0026#34;...\u0026#34;) if err := downloadAndExtractLayer(repoName, l.Digest, token, destDir); err != nil { log.Fatal(\u0026#34;layer extract failed:\u0026#34;, err) } } fmt.Printf(\u0026#34;image extracted to %s/\\n\u0026#34;, destDir) } library/ 접두사는 Docker Hub의 네이밍 규칙이다. alpine, nginx 같은 공식 이미지는 실제로 library/alpine, library/nginx라는 이름으로 레지스트리에 저장되어 있다.\n추출된 rootfs는 rootfs_alpine/ 같은 디렉토리에 저장되고, 기존의 container 함수가 이 경로를 사용하여 컨테이너를 실행한다.\nfunc main() { switch os.Args[1] { case \u0026#34;run\u0026#34;: run() case \u0026#34;child\u0026#34;: child() case \u0026#34;container\u0026#34;: container() case \u0026#34;pull\u0026#34;: image := os.Args[2] tag := \u0026#34;latest\u0026#34; if len(os.Args) \u0026gt; 3 { tag = os.Args[3] } pull(image, tag) default: log.Fatal(\u0026#34;unknown command\u0026#34;) } } main.go에서 pull 서브 커맨드를 연결해주면 준비는 끝났다\n테스트 #pjt@lima-default:~$ go run . pull alpine token acquired found 1 layer(s) downloading layer 1/1: sha256:d8ad8cd72600f46cc0... image extracted to rootfs_alpine/ pjt@lima-default:~$ go run . run alpine /bin/sh / # cat /etc/os-release NAME=\u0026#34;Alpine Linux\u0026#34; ID=alpine VERSION_ID=3.23.3 PRETTY_NAME=\u0026#34;Alpine Linux v3.23\u0026#34; HOME_URL=\u0026#34;https://alpinelinux.org/\u0026#34; BUG_REPORT_URL=\u0026#34;https://gitlab.alpinelinux.org/alpine/aports/-/issues\u0026#34; / # ls bin dev etc home lib ... 수동으로 rootfs를 준비할 필요 없이, Docker Hub에서 이미지를 가져와 바로 컨테이너를 실행할 수 있게 되었다.\n마무리 #3편에 걸쳐 rootless 나름의 컨테이너 런타임을 만들었다.\n1편: namespace + pivot_root로 프로세스를 격리 2편: cgroup + seccomp + capability로 리소스를 제한하고 syscall을 필터링 3편: OCI 표준을 따라 Docker Hub에서 이미지를 가져오기 docker run이라는 한 줄의 명령어 뒤에는 namespace 분리, cgroup 위임, seccomp BPF 필터, capability drop, OCI 레지스트리 인증과 manifest 파싱, 레이어 추출이 숨어있었다.\n컨테이너는 마법이 아니라 리눅스 커널이 제공하는 기능들의 조합이었고, 그 각각을 직접 호출하면서 \u0026ldquo;왜 이런 구조인지\u0026quot;를 이해할 수 있었다.\n물론 지금껏 구현한 코드는 프로덕션 런타임과는 거리가 멀다. 네트워크 브릿지도 없고, 이미지 캐싱도 없고, 다운로드한 blob의 SHA256 해시를 검증하여 변조나 손상을 탐지하는 digest 검증도 빠져있다.\n하지만 이 시리즈의 목적은 완성도 높은 런타임을 만드는 것이 아니라, 컨테이너가 어떻게 동작하는지를 밑바닥부터 이해하는 것이었다.\n바퀴를 굳이 다시 발명할 필요는 없지만 바퀴에 대해 잘 알고 있으면 더 잘 굴릴 수 있지 않을까 싶다\n이번에 직접 컨테이너를 구현하면서 느낀 것은,\n나는 내가 컨테이너를 좋아하는 줄 알았는데 어쩌면 리눅스를 좋아하는 거였을지도 모르겠다는 생각이 든다\n프로젝트 전체코드 : https://github.com/opjt/nootainer\n","date":"2026-03-24","permalink":"https://blog.opjt.dev/posts/14/","section":"posts","summary":"\u003cp\u003e전편 \n      \n    \u003ca href=\"https://blog.opjt.dev/posts/13/\"\u003e격리만으로는 부족하다\u003c/a\u003e에서 cgroup, seccomp, capability로 컨테이너의 보안을 강화했다.\n이것으로 얼추 rootless 컨테이너 런타임의 핵심 구현은 끝났다.\u003c/p\u003e\n\u003cp\u003e하지만 한 가지 불편한점이 있는데. 매번 컨테이너를 실행하려면 rootfs를 직접 준비해야 한다.\u003cbr\u003e\n지금까지는 \u003ccode\u003edocker export\u003c/code\u003e로 기존 컨테이너의 파일시스템을 뽑아서 사용했다.\u003c/p\u003e","title":"컨테이너 이미지는 어디서 오는가?"},{"content":"전편 컨테이너는 마법이 아니다에서는 namespace 와 pivot_root를 통하여 격리 환경을 구성하는데 집중하였다\n호스트와 분리된 PID, 파일시스템, hostname을 가진 환경을 만들었지만, 이것만으로 안전한 컨테이너라고 할 수는 없다\n현재 상태에서는:\n컨테이너가 호스트의 메모리를 전부 소진 가능 reboot 같은 위험한 시스템 콜을 호출할 수 있다 이번 글에서는 cgroup을 통해 리소스를 제한하고, seccomp을 사용하여 시스템 콜을 필터링하여 보안을 강화해본다.\ncgroup #Fork bomb #지금껏 만든 컨테이너 환경 안에서 다음 명령을 실행하면 어떻게 될까?\n:(){ :|:\u0026amp; };: 이 한 줄의 명령은 프로세스가 자기 자신을 무한히 복제하여 시스템의 PID와 메모리를 전부 소진하게 된다.\nnamespace로 격리되어 있어도 호스트의 리소스는 공유되어 있기 때문에, 컨테이너 하나가 호스트 전체를 먹통으로 만들 수 있다.\n이를 방지하는 것이 cgroup (Control Group)이다.\ncgroup이란 #cgroup은 프로세스 그룹의 리소스 사용량을 제한하고 모니터링하는 커널 기능이다. 제한할 수 있는 리소스는 다양하다\npids.max — 최대 프로세스 수 memory.max — 최대 메모리 사용량 cpu.max — CPU 사용 시간 제한 cgroup v2에서는 이 설정이 /sys/fs/cgroup/ 아래의 파일로 관리된다. 파일에 값을 쓰면 해당 cgroup에 속한 프로세스들에게 제한이 적용된다.\n테스트를 위해 pivotRoot(\u0026quot;rootfs\u0026quot;) 함수를 주석처리하여 잠시 host의 파일마운트를 유지한다\n이후 컨테이너환경에서 cgroup을 설정해보자\npjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ id uid=502(pjt) gid=1000(pjt) groups=1000(pjt) pjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ go run . run /bin/bash root@nootainer:/Users/pjt/Projects/PJT/nootainer# cd /sys/fs/cgroup/user.slice/user-502.slice/user@502.service root@nootainer:/sys/fs/cgroup/user.slice/user-502.slice/user@502.service# ll -lrt total 0 -rw-r--r-- 1 root root 0 Mar 10 21:52 cgroup.type -rw-r--r-- 1 root root 0 Mar 10 21:52 cgroup.procs ...(생략) user.slice로 들어오다보면 파일 소유자가 root 인 디렉토리가 있다.(다른 파일들이 nobody로 보이는 이유는 uid_group을 pjt-root 한개만 했기 때문)\nroot@nootainer:/sys/fs/cgroup/user.slice/user-502.slice/user@502.service# mkdir test root@nootainer:/sys/fs/cgroup/user.slice/user-502.slice/user@502.service# ll -lrt test total 0 drwxr-xr-x+ 8 root root 0 Mar 23 21:27 ../ -rw-r--r-- 1 root root 0 Mar 23 21:27 cgroup.type -rw-r--r-- 1 root root 0 Mar 23 21:27 cgroup.procs -rw-r--r-- 1 root root 0 Mar 23 21:27 cgroup.threads -r--r--r-- 1 root root 0 Mar 23 21:27 cgroup.controllers -rw-r--r-- 1 root root 0 Mar 23 21:27 cgroup.subtree_control -r--r--r-- 1 root root 0 Mar 23 21:27 cgroup.events -rw-r--r-- 1 root root 0 Mar 23 21:27 cgroup.max.descendants -rw-r--r-- 1 root root 0 Mar 23 21:27 cgroup.max.depth ... 이후 새로 디렉토리를 생성하게 되면 신기하게도 안에 파일들이 자동으로 채워진다.\n여기 있는 cpu.max, memory.max pids.max 같은 파일들을 수정하고 제한받을 pid를 cgroup.procs 에 넣어주면 설정은 끝난다.\nroot@nootainer:/sys/fs/cgroup/user.slice/user-502.slice/user@502.service# echo $$ 8 root@nootainer:/sys/fs/cgroup/user.slice/user-502.slice/user@502.service# echo \u0026#34;8\u0026#34; \u0026gt; cgroup.procs bash: echo: write error: Permission denied 하지만 안타깝게도 설정할 수가 없다 왜그럴까?\nRootless에서의 문제 #User namespace 안에서 /sys/fs/cgroup/ 하위에 직접 디렉토리를 만들고 pids.max 같은 제한값을 설정하는 것까지는 가능하다. 파일 소유자가 내 UID이기 때문이다.\n하지만 프로세스를 해당 cgroup에 넣는 것이 문제다. cgroup.procs에 PID를 써서 프로세스를 이동시키려면, 출발지(부모 cgroup)와 도착지 양쪽의 cgroup.procs에 쓰기 권한이 필요하다. 부모 cgroup의 cgroup.procs는 호스트 root 소유이므로 쓰기 권한이 없다.\nroot@nootainer:/# cat /proc/self/cgroup 0::/user.slice/user-502.slice/session-4.scope root@nootainer:/# echo $$ 8 root@nootainer:/# cat /sys/fs/cgroup/user.slice/user-502.slice/session-4.scope/cgroup.procs ...(생략) 8 현재 프로세스가 속한 cgroup을 확인하는 방법 제한값은 설정할 수 있어도 프로세스를 넣을 수 없으니 의미가 없다. 이를 해결하는 것이 systemd의 cgroup delegation이다.\nsystemd-run --quiet --user --scope -p Delegate=yes -- \u0026lt;command\u0026gt; systemd-run --user --scope는 현재 유저 세션에 새로운 scope (cgroup 단위)를 만들어준다. Delegate=yes를 설정하면 해당 scope의 cgroup 파일에 대한 쓰기 권한을 위임받는다.\n이렇게 하면 root 없이도 cgroup 제한을 설정할 수 있다.\npjt@lima-default:/$ systemd-run --quiet --user --scope -p Delegate=yes -- /bin/bash pjt@lima-default:/$ cat /proc/self/cgroup 0::/user.slice/user-502.slice/user@502.service/app.slice/run-p245227-i245226.scope pjt@lima-default:/$ echo \u0026#34;0\u0026#34; \u0026gt; /sys/fs/cgroup/user.slice/user-502.slice/user@502.service/app.slice/run-p245227-i245226.scope/pids.max pjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ ps bash: fork: retry: Resource temporarily unavailable ^Cbash: fork: Interrupted system call pids.max 값을 0으로 수정한 결과 ps 명령어를 입력하면 더이상 fork할 수 없는 것을 확인할 수 있다. (ctrl+D 로 나오면 cgroup scope는 자동 정리된다)\n이렇게 root 없이도 cgroup 제한을 설정할 수 있다. 이제 이걸 코드로 구현해보자\n구현 #이전 글에서 run → child 2단계 구조였다. run에서 namespace를 만들고, child에서 setup 후 사용자 명령을 실행했다.\nrun → child ├ namespace setup (hostname, /proc, pivot_root) └ exec(사용자 명령) 여기에 cgroup을 추가하려면 문제가 생긴다. run에서 systemd-run으로 scope를 만들어야 하는데, 기존 run은 직접 clone()으로 child를 실행했다. scope 생성과 namespace 생성을 한 단계에서 동시에 할 수 없으므로, 단계를 하나 더 분리해야 한다.\n// run: systemd-run으로 cgroup scope 생성 func run() { exe, err := os.Executable() if err != nil { log.Fatal(err) } args := append([]string{ \u0026#34;--quiet\u0026#34;, \u0026#34;--user\u0026#34;, \u0026#34;--scope\u0026#34;, \u0026#34;-p\u0026#34;, \u0026#34;Delegate=yes\u0026#34;, \u0026#34;--\u0026#34;, exe, \u0026#34;child\u0026#34;, }, os.Args[2:]...) cmd := exec.Command(\u0026#34;systemd-run\u0026#34;, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { log.Fatal(err) } } child에서는 /proc/self/cgroup을 읽어 현재 scope의 경로를 찾고, 해당 경로의 cgroup 파일에 제한값을 쓴다:\nfunc getCgroupPath() string { data, err := os.ReadFile(\u0026#34;/proc/self/cgroup\u0026#34;) if err != nil { log.Fatal(err) } line := strings.TrimSpace(string(data)) parts := strings.SplitN(line, \u0026#34;:\u0026#34;, 3) return filepath.Join(\u0026#34;/sys/fs/cgroup\u0026#34;, parts[2]) } func setCgroupV2() { cgroupPath := getCgroupPath() os.WriteFile(filepath.Join(cgroupPath, \u0026#34;pids.max\u0026#34;), []byte(\u0026#34;20\u0026#34;), 0644) os.WriteFile(filepath.Join(cgroupPath, \u0026#34;memory.max\u0026#34;), []byte(\u0026#34;100M\u0026#34;), 0644) } 기존 child가 하던 namespace setup은 새로운 container 단계로 내려가고, child는 cgroup 설정을 담당한다.\ncgroup 설정을 하려면 두 가지 조건이 동시에 만족되어야 하기 때문이다\nscope가 이미 생성되어 있을 것 — cgroup 경로가 존재해야 파일에 쓸 수 있다 user namespace에 아직 진입하지 않았을 것 — 앞에서 봤듯이 user namespace 안에서는 cgroup 파일에 쓸 수 없다 단계 scope 존재? namespace 밖? cgroup 설정 가능? run ✗ (이제 막 systemd-run 호출) ✓ ✗ child ✓ (scope 안에서 실행됨) ✓ ✓ container ✓ ✗ (이미 user namespace 안) ✗ child만이 두 조건을 모두 만족한다.\nnootainer run \u0026lt;cmd\u0026gt; │ ▼ ┌─────────┐ │ run │ systemd-run --user --scope (cgroup scope 생성) └────┬────┘ ▼ ┌─────────┐ │ child │ cgroup 제한 설정 (pids.max, memory.max) ← 여기 └────┬────┘ clone(NEWUSER|NEWUTS|NEWNS|...) ▼ ┌───────────┐ │ container │ pivot_root, setup, exec └───────────┘ 그래서 기존 2단계 사이에 cgroup 설정을 위한 단계가 끼어들면서 위와 같은 3단계 구조가 된다.\n테스트 #pids.max를 20으로 설정한 상태에서 fork bomb을 실행하면:\npjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ go run . run /bin/bash root@nootainer:/Users/pjt/Projects/PJT/nootainer# :(){ :|:\u0026amp; };: [1] 17 root@nootainer:/Users/pjt/Projects/PJT/nootainer# bash: fork: retry: Resource temporarily unavailable 프로세스 수가 20개로 제한되어 fork bomb이 호스트까지 번지지 않는다.\nSeccomp BPF: 시스템 콜 필터링 #왜 필요한가 #cgroup으로 리소스를 제한했지만, 컨테이너 안에서 호출할 수 있는 시스템 콜에는 아직 제한이 없다.\n예를 들어 reboot syscall은 namespace 안에서도 호출 자체는 가능하다. 커널이 권한 체크를 하여 거부하긴 하지만, 1편에서 다뤘던 것처럼 커널 코드 경로에 버그가 있으면 문제가 될 수 있다.\nseccomp은 이 문제를 해결한다. 커널 권한 체크에 도달하기 전에 syscall 자체를 차단한다.\nseccomp과 BPF #seccomp (Secure Computing Mode)은 프로세스가 사용할 수 있는 시스템 콜을 제한하는 커널 기능이다.\nseccomp에는 두 가지 모드가 있다:\nstrict mode — read, write, exit, sigreturn 4개만 허용 filter mode — BPF 프로그램으로 syscall별 정책을 직접 정의 컨테이너에서는 filter mode를 사용한다. 여기서 BPF (Berkeley Packet Filter)는 원래 네트워크 패킷 필터링을 위해 만들어진 바이트코드 VM인데, seccomp에서는 이를 syscall 필터링에 재활용한다.\n동작 방식에 대해 간단히 알아보자면\n프로세스가 prctl로 BPF 필터를 커널에 등록해두면, 이후 해당 프로세스가 syscall을 호출할 때마다 커널은 실제 syscall을 실행하기 전에 등록된 BPF 필터를 먼저 실행한다. 필터는 syscall 번호를 보고 허용할지 차단할지를 결정한다. 차단으로 판정되면 syscall은 커널 코드에 도달하지 못하고 바로 에러가 반환된다. BPF 바이트코드 직접 작성 #해당 구현에서는 libseccomp 같은 잘 만들어져있는 라이브러리를 사용하지 않고 BPF 바이트코드를 직접 작성한다.\nseccomp.go 파일을 생성 후 분리하여 작성한다. BPF 필터는 커널이 이해하는 명령어 블록들을 직접 조합하는 방식이다.\n각 블록은 하나의 연산을 나타내고, 이 블록들을 배열로 나열하면 그것이 곧 필터 프로그램이 된다.\n스크래치 블록처럼 \u0026ldquo;값을 읽어라\u0026rdquo;, \u0026ldquo;비교해라\u0026rdquo;, \u0026ldquo;점프해라\u0026rdquo;, \u0026ldquo;결과를 반환해라\u0026rdquo; 같은 기본 동작을 조합하여 원하는 필터 로직을 만든다.\n사용할 수 있는 연산의 종류는 커널이 정의한 상수로 표현된다\nconst ( BPF_LD = 0x00 // 값을 읽어오는 명령 BPF_JMP = 0x05 // 조건부 점프 BPF_RET = 0x06 // 결과 반환 (허용/차단) BPF_W = 0x00 // 4바이트(Word) 단위로 읽기 BPF_ABS = 0x20 // 절대 오프셋에서 읽기 BPF_JEQ = 0x10 // 같으면 점프 SECCOMP_RET_ALLOW = 0x7fff0000 // syscall 허용 SECCOMP_RET_ERRNO = 0x00050000 // syscall 차단, 에러 반환 SECCOMP_DATA_NR_OFF = 0 // syscall 번호의 오프셋 SECCOMP_DATA_ARCH_OFF = 4 // 아키텍처 정보의 오프셋 AUDIT_ARCH_AARCH64 = 0xC00000B7 ) 이 상수들을 비트 OR(|)로 조합하면 하나의 명령이 된다. 예를 들어 BPF_LD|BPF_W|BPF_ABS는 \u0026ldquo;절대 오프셋에서 4바이트를 읽어라\u0026quot;라는 뜻이다.\nBPF 명령어는 sockFilter 구조체로 표현된다\ntype sockFilter struct { Code uint16 // 연산코드 (load, jump, return 등) Jt uint8 // 조건이 참이면 점프할 거리 Jf uint8 // 조건이 거짓이면 점프할 거리 K uint32 // 상수값 } linux/filter.h 참고 그리고 필터 작성을 위해 두 가지 헬퍼 함수를 만든다\n// 조건 없이 실행하는 명령 func bpfStmt(code uint16, k uint32) sockFilter { return sockFilter{Code: code, K: k} } // 조건부 비교 후 점프 func bpfJump(code uint16, k uint32, jt, jf uint8) sockFilter { return sockFilter{Code: code, Jt: jt, Jf: jf, K: k} } stmt(statement)는 조건 분기 없이 반드시 실행되는 명령이다 조건이 없기 때문에 Jt, Jf 를 넣지않음 이를 사용하여 필터를 작성해보자\nfunc setupSeccomp() { filter := []sockFilter{ // 아키텍처 체크 (aarch64인지 확인) bpfStmt(BPF_LD|BPF_W|BPF_ABS, SECCOMP_DATA_ARCH_OFF), // 아키텍처 정보 로드 bpfJump(BPF_JMP|BPF_JEQ, AUDIT_ARCH_AARCH64, 1, 0), // aarch64이면 다음으로, 아니면 차단 bpfStmt(BPF_RET, SECCOMP_RET_ERRNO|uint32(syscall.EPERM)), // syscall 번호 로드 bpfStmt(BPF_LD|BPF_W|BPF_ABS, SECCOMP_DATA_NR_OFF), // 차단 목록 체크 (매칭되면 ERRNO로, 아니면 다음으로) bpfJump(BPF_JMP|BPF_JEQ, 142, 4, 0), // SYS_REBOOT bpfJump(BPF_JMP|BPF_JEQ, 104, 3, 0), // SYS_KEXEC_LOAD bpfJump(BPF_JMP|BPF_JEQ, 294, 2, 0), // SYS_KEXEC_FILE_LOAD // 만약 로드한 syscall이 97번(SYS_UNSHARE)과 같다면 1만큼 점프하여 ERR로 가게 된다 bpfJump(BPF_JMP|BPF_JEQ, 97, 1, 0), // SYS_UNSHARE // 허용 / 차단 bpfStmt(BPF_RET, SECCOMP_RET_ALLOW), bpfStmt(BPF_RET, SECCOMP_RET_ERRNO|uint32(syscall.EPERM)), } // 커널에 필터 로드 prog := sockFprog{Len: uint16(len(filter)), Filter: \u0026amp;filter[0]} _, _, errno := syscall.RawSyscall( syscall.SYS_PRCTL, syscall.PR_SET_SECCOMP, SECCOMP_MODE_FILTER, uintptr(unsafe.Pointer(\u0026amp;prog)), ) if errno != 0 { log.Fatal(\u0026#34;seccomp load failed: \u0026#34;, errno) } } 필터의 흐름을 그림으로 보면 아래와 같은 느낌이다\nsyscall 발생 │ ▼ 아키텍처가 aarch64인가? ──NO──→ ERRNO (차단) │ YES ▼ syscall 번호 로드 │ ▼ SYS_REBOOT인가? ──YES──→ ERRNO (차단) │ NO ▼ SYS_KEXEC_LOAD인가? ──YES──→ ERRNO (차단) │ NO ▼ SYS_UNSHARE인가? ──YES──→ ERRNO (차단) │ NO ▼ ALLOW (허용) 필터 배열을 만들었으면 커널에 등록해야 한다.\nsockFprog 구조체에 필터의 길이와 포인터를 담고, prctl syscall로 커널에 전달한다. SECCOMP_MODE_FILTER를 지정하면 커널은 이 BPF 프로그램을 현재 프로세스에 부착하고, 이후 모든 syscall에 대해 필터를 실행한다. 테스트 #기존 seccomp이 없는 상태에서는 컨테이너 안에서 추가 namespace를 만들 수 있다:\nroot@nootainer:/# unshare --user sh $ \u0026lt;- 성공. 컨테이너 안에서 또 다른 namespace 생성 컨테이너 안에서 추가 격리 환경을 만들 수 있다는 것은 공격자가 탐지를 회피하거나 추가적인 커널 공격 표면을 열 수 있다는 뜻이다.\n이제 setupSeccomp() 함수를 pivotRoot() 밑에 추가하여 syscall이 제한되는지 확인해본다.\npjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ go run . run /bin/sh / # unshare --user sh unshare: unshare(0x10000000): Operation not permitted SYS_UNSHARE가 차단되어 커널에 도달하기 전에 EPERM이 반환된다.\nCapability #마지막으로 capability에 대해 간단히 짚고 넘어가자.\nuser namespace 안에서 root(UID 0)는 해당 namespace 범위 내에서 full capability를 가진다. CAP_SYS_ADMIN, CAP_NET_RAW, CAP_SYS_PTRACE 등 모든 권한이 열려 있다는 뜻이다. 호스트에 직접적인 영향은 없지만, namespace 안에서의 공격 표면을 넓힌다.\nseccomp이 syscall 단위로 차단한다면, capability drop은 권한 단위로 불필요한 것을 제거하는 방식이다. 같은 목적의 다른 계층이라 볼 수 있다.\n//capability.go var dropCaps = []uintptr{ 21, // CAP_SYS_ADMIN 13, // CAP_NET_RAW 19, // CAP_SYS_PTRACE 16, // CAP_SYS_MODULE 22, // CAP_SYS_BOOT 23, // CAP_SYS_NICE 24, // CAP_SYS_RESOURCE 25, // CAP_SYS_TIME } func dropCapabilities() { for _, cap := range dropCaps { _, _, err := syscall.RawSyscall(syscall.SYS_PRCTL, PR_CAPBSET_DROP, cap, 0) if err != 0 { log.Fatal(err) } } } prctl(PR_CAPBSET_DROP)으로 capability bounding set에서 해당 capability를 제거한다. 한번 drop하면 다시 얻을 수 없다.\n실제로 Docker도 컨테이너 실행 시 기본적으로 대부분의 capability를 drop한다. CAP_NET_RAW, CAP_SYS_CHROOT 등 최소한의 것만 남기고 나머지는 제거한다.\n여기서 docker run --privileged가 왜 위험한지 알 수 있다.\n이 옵션은 모든 capability를 부여하고, seccomp 필터를 비활성화하며, /dev 아래의 호스트 디바이스에 접근할 수 있게 한다 지금까지 쌓아온 방어 계층을 전부 무력화하는 옵션인 셈이다.\n예를 들어 --privileged 컨테이너 안에서는 호스트의 디스크를 직접 마운트할 수 있다:\n# privileged 컨테이너 안에서 mount /dev/sda1 /mnt ls /mnt # 호스트의 루트 파일시스템이 보인다 CAP_SYS_ADMIN이 있으니 mount syscall이 허용되고, 호스트 디바이스에 접근 가능하니 /dev/sda1을 읽을 수 있다. 사실상 호스트의 root와 다를 바 없는 상태가 된다.\n지금 구현하고 있는 방식은 rootless 컨테이너이므로 호스트의 진짜 root 권한이 애초에 없어 --privileged 같은 문제와는 거리가 멀다. 하지만 rootful 환경에서 습관적으로 --privileged를 붙이는 것이 얼마나 위험한지는 알아둘 필요가 있다.\n마무리 #namespace(격리) + cgroup(리소스 제한) + seccomp(syscall 필터링) + capability(권한 축소).\n이 조합이 컨테이너 보안의 기본 구성이다. 각각 다른 계층에서 방어하며, 하나가 뚫려도 나머지가 버텨주는 다층 방어(defense in depth) 구조를 이룬다.\n이것으로 rootless 컨테이너 런타임의 핵심 구현은 끝이다. root 권한 없이 프로세스를 격리하고, 리소스를 제한하고, 위험한 syscall을 차단하는 컨테이너를 만들었다.\n다음 편에서는 부가적인 부분을 다룬다. 지금까지는 rootfs를 로컬에서 직접 준비해야 했는데, OCI 표준을 통해 Docker Hub에서 이미지를 직접 가져오는 방법을 구현해본다.\nnootainer의 전체 코드는 GitHub에서 확인할 수 있다.\n","date":"2026-03-23","permalink":"https://blog.opjt.dev/posts/13/","section":"posts","summary":"\u003cp\u003e전편 \n      \n    \u003ca href=\"https://blog.opjt.dev/posts/12/\"\u003e컨테이너는 마법이 아니다\u003c/a\u003e에서는 namespace 와 pivot_root를 통하여 격리 환경을 구성하는데 집중하였다\u003cbr\u003e\n호스트와 분리된 PID, 파일시스템, hostname을 가진 환경을 만들었지만, 이것만으로 안전한 컨테이너라고 할 수는 없다\u003c/p\u003e","title":"rootless 컨테이너 만들기 - cgroup 과 seccomp"},{"content":"처음 docker를 사용하며 컨테이너를 만들고 exec -it를 통해 컨테이너 내부에 처음 들어왔을 때 경험은 참 신기했다\nps 명령어를 치면 몇 안되는 프로세스들과 낯선 파일 구조들은 마치 VM처럼 느껴졌다\n블랙박스의 영역처럼 느껴졌던 이런 컨테이너의 격리 기술은 간단한 명령어나 몇 줄 안되는 소스코드로 생각보다 쉽게 구현해볼 수 있다\n컨테이너 수준의 격리를 구성하기 위해서 핵심 3가지 기술들이 사용된다.\nnamespace : 자원 격리 cgroup : 자원 제한 pivot root : 루트 파일시스템 격리 해당 포스트에서는 왜 루트리스 컨테이너를 사용해야하는지와 직접 rootless 컨테이너를 Go를 통해 만들어본다\nNamespace #namespace는 컨테이너 기술의 가장 근본적인 격리 기능을 제공하는 리눅스 커널 기능이다\n마치 같은 회사 건물이지만 여러 층을 이용하여 분리된 공간을 사용하는 것 처럼, 네임스페이스는 단일 운영체제 환경에서 프로세스그룹이 서로 격리된 환경을 갖도록 만들어 준다.\n이것이 가능한 이유는 네임스페이스가 특정 프로세스에게 시스템의 자원을 마치 자신만 사용하는 것처럼 보이도록 view를 제한하기 때문이다\n시스템의 다양한 자원들을 대상으로 각각 다른 종류의 네임스페이스 기능들을 지원한다.\nNamespace 격리 대상 기능 User UID/GID 컨테이너 안에서 root처럼 동작하되, 호스트에는 영향 없게 PID 프로세스 ID 컨테이너 안에서 PID 1부터 시작, 호스트 프로세스 안 보임 UTS hostname 컨테이너마다 독립적인 hostname 설정 Mount 파일시스템 마운트 컨테이너마다 독립적인 파일시스템 트리 IPC 프로세스간 통신 shared memory, semaphore 등 격리 Network 네트워크 스택 독립적인 네트워크 인터페이스, IP, 포트 unshare 실습 #글로 이해하는 것 보다 직접 느껴보는 게 빠르다\nLinux에서는 unshare 명령어를 통해 네임스페이스를 직접 만들 수 있다\nUnix Time-Sharing System namespace #가장 기본적인 네임스페이스인 UTS 네임스페이스를 만들어보겠다.\n유닉스 시분할 시스템 네임스페이스는 말로는 거창한데\n단순히 hostname을 격리하는 기능을 지니고 있다.\npjt@lima-default:~$ sudo unshare --uts /bin/sh # hostname lima-default # hostname container # hostname container 이후 다른 쉘에서 hostname을 입력하면 lima-default 가 나오는 것을 볼 수 있다\n# ls -lrt /proc/self/ns total 0 lrwxrwxrwx 1 root root 0 Mar 22 15:43 time_for_children -\u0026gt; \u0026#39;time:[4026531834]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 time -\u0026gt; \u0026#39;time:[4026531834]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 cgroup -\u0026gt; \u0026#39;cgroup:[4026531835]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 mnt -\u0026gt; \u0026#39;mnt:[4026531832]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 user -\u0026gt; \u0026#39;user:[4026531837]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 pid_for_children -\u0026gt; \u0026#39;pid:[4026531836]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 pid -\u0026gt; \u0026#39;pid:[4026531836]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 ipc -\u0026gt; \u0026#39;ipc:[4026531839]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 uts -\u0026gt; \u0026#39;uts:[4026532929]\u0026#39; lrwxrwxrwx 1 root root 0 Mar 22 15:43 net -\u0026gt; \u0026#39;net:[4026531833]\u0026#39; pjt@lima-default:~$ ls -lrt /proc/self/ns total 0 lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 time_for_children -\u0026gt; \u0026#39;time:[4026531834]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 time -\u0026gt; \u0026#39;time:[4026531834]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 cgroup -\u0026gt; \u0026#39;cgroup:[4026531835]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 mnt -\u0026gt; \u0026#39;mnt:[4026531832]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 user -\u0026gt; \u0026#39;user:[4026531837]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 pid_for_children -\u0026gt; \u0026#39;pid:[4026531836]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 pid -\u0026gt; \u0026#39;pid:[4026531836]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 ipc -\u0026gt; \u0026#39;ipc:[4026531839]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 uts -\u0026gt; \u0026#39;uts:[4026531838]\u0026#39; lrwxrwxrwx 1 pjt pjt 0 Mar 22 15:43 net -\u0026gt; \u0026#39;net:[4026531833]\u0026#39; proc/self/ns 를 살펴보면 uts 네임스페이스 번호만 다른 것을 확인할 수 있다\nPID namespace #다음은 프로세스아이디를 격리해보자\nsudo unshare --pid --fork sh pjt@lima-default:~$ sudo unshare --pid --fork sh # echo $$ 1 echo $$ 는 현재 쉘 프로세스의 pid를 나타내는데 1인 모습을 볼 수 있다\nhostOS의 pid 1은 init 프로세스다 하지만 unshare 한 쉘 내에서 ps -ef를 하면 수많은 프로세스들이 보인다\n이유는 ps로 보여지는 프로세스들은 /proc 디렉토리를 읽어와서 보여주는데 아직 /proc 디렉터리를 보여주는 마운트 네임스페이스가 아직 그대로 바라보고 있기 때문이다\nMount namespace #이때 마운트 네임스페이스를 생성하여 /proc 디렉토리를 새로 올려줘보자\npjt@lima-default:~$ sudo unshare --mount --pid sh # mount -t proc proc /proc # ps -ef sh: 2: Cannot fork # pjt@lima-default:~$ sudo unshare --mount --pid --fork sh # mount -t proc proc /proc # ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 0 16:05 pts/6 00:00:00 sh root 3 1 0 16:05 pts/6 00:00:00 ps -ef 두번째 결과를 보면 전과 달리 격리된 pid 목록들만 보여지게 된다\n첫번째 명령에서 fork하지 못하는 이유는 아래와 같다\n--fork 없이 unshare를 하게 될 경우 sh 프로세스가 아직 새 PID namespace의 맴버가 아니게 된다(PID namespace를 만들기만 하고, 기존 namespace에 남아 있음) /proc 를 새로 마운트하면 새 PID namespace의 proc가 올라가게 된다, 하지만 거기엔 아직 sh 프로세스가 포함되어있지 않기 때문에 그 상태에서 ps 를 하면 내부적으로 fork()를 수행하는데 새 PID namespace에 init(PID 1) 프로세스가 없어서 커널이 fork을 거부하게 된다 PID namespace 에는 반드시 PID 1이 존재해야 자식 프로세스를 만들 수 있다 User namespace #지금까지 실습에서 모두 sudo 명령어를 사용하였다. namespace를 생성하려면 root 권한이 필요하기 때문이다\n하지만 컨테이너를 생성하기 위해 항상 루트권한이 필요하다면 이는 보안적으로 큰 리스크가 된다.\n컨테이너 런타임에서 취약점이 발견될 경우, 공격자는 호스트의 root 권한을 그대로 사용할 수 있기 때문이다\nuser namespace는 다른 네임스페이스와 다르게 비루트 권한으로도 생성이 가능하다.\npjt@lima-default:~$ id uid=502(pjt) gid=1000(pjt) groups=1000(pjt) pjt@lima-default:~$ unshare --user --map-root-user /bin/sh # id uid=0(root) gid=0(root) groups=0(root) sudo 없이 root가 된 모습이다\n# cat /proc/$$/uid_map 0 502 1 # root가 된 이유는 unshare 명령에서 사용한 --map-root-user 때문인데\n해당 옵션을 사용하면 uid_map을 자동으로 설정해준다\nuid_map 은 user namespace에서 적용되는 유저 매핑테이블이다\n차례대로 namespace내 UID, 호스트 UID, 매핑 개수를 의미한다\n즉 0 502 1 은 namespace 안의 uid 0부터 1개(매핑개수)를 호스트의 502 uid로 매핑하겠다 라는 뜻이다.\nnamespace 안에서는 root(uid:0) 으로 보이지만 호스트에서는 여전히 일반 유저인 것이다\n참고로 init namespace(호스트)를 살펴보면 :\npjt@lima-default:~$ cat /proc/$$/uid_map 0 0 4294967295 모든 UID가 1:1로 매핑된 모습이다 (격리가 없는 상태) 여기서 핵심은 user namespace 안에서 root가 되면, 그 안에서 다른 namespace도 생성할 수 있다는 점이다.\npjt@lima-default:~$ unshare --user --map-root-user --pid --fork --mount /bin/sh # mount -t proc proc /proc # ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 0 16:52 pts/4 00:00:00 /bin/sh root 3 1 0 16:53 pts/4 00:00:00 ps -ef unshare가 여러 namespace를 동시에 생성할 때 user namespace를 먼저 만들기 때문에, 그 안에서 root 권한을 얻은 상태로 나머지 namespace를 생성할 수 있다\n이것이 rootless 컨테이너의 원리이다\nContainer with Go #unshare 명령어를 통해 컨테이너 격리를 대략적으로 실습해봤다\n이제 Go로 직접 컨테이너 런타임을 만들어보자.\n컨테이너를 만들려면 격리된 namespace 안에서 프로세스를 실행해야 한다.\nGo에서는 exec 패키지를 사용하여 자식 프로세스를 실행할 수 있는데, 이때 SysProcAttr에 Cloneflags를 설정하면 자식 프로세스를 생성하면서 동시에 새 namespace를 설정할 수 있다.\n가장 단순한 구현부터 시작해보자\nfunc main() { switch os.Args[1] { case \u0026#34;run\u0026#34;: run() default: log.Fatal(\u0026#34;unknown command\u0026#34;) } } func run() { cmd := exec.Command(os.Args[2], os.Args[3:]...) cmd.SysProcAttr = \u0026amp;syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWNET, UidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: os.Getuid(), Size: 1}, }, GidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: os.Getgid(), Size: 1}, }, } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { log.Fatal(err) } } UidMappings는 앞서 살펴본 uid_map과 동일하다. 실행 후 결과를 살펴보자\npjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ go run . run sh # hostname lima-default # ps -ef | head -5 UID PID PPID C STIME TTY TIME CMD nobody 1 0 0 Mar16 ? 00:01:09 /usr/lib/systemd/systemd --system --deserialize=67 nobody 2 0 0 Mar16 ? 00:00:00 [kthreadd] nobody 3 2 0 Mar16 ? 00:00:00 [pool_workqueue_release] nobody 4 2 0 Mar16 ? 00:00:00 [kworker/R-rcu_gp] # 우리가 알던 컨테이너의 모습과는 다르다\nhostname 그대로 /proc가 호스트 것이라 호스트 프로세스가 전부 보임 namespace는 생성되었지만, hostname 설정 및 /proc 마운트 같은 셋업을 할 타이밍이 없다.\n쉘이 바로 실행되어 버리기 때문이다.\n이를 해결하기 위하여 사용자 명령을 바로 실행하는 것이 아닌, 자기 자신을 다시 실행하여 namespace 안에서 setup 코드를 설정할 수 있게 한다.\n나 자신을 내가 실행시키기(re-exec 패턴) #func main() { switch os.Args[1] { case \u0026#34;run\u0026#34;: run() case \u0026#34;child\u0026#34;: child() default: log.Fatal(\u0026#34;unknown command\u0026#34;) } } func run() { cmd := exec.Command(\u0026#34;/proc/self/exe\u0026#34;, append([]string{\u0026#34;child\u0026#34;}, os.Args[2:]...)...) cmd.SysProcAttr = \u0026amp;syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWNET, UidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: os.Getuid(), Size: 1}, }, GidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: os.Getgid(), Size: 1}, }, } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { log.Fatal(err) } } func child() { // namespace 안에서 setup syscall.Sethostname([]byte(\u0026#34;nootainer\u0026#34;)) syscall.Mount(\u0026#34;proc\u0026#34;, \u0026#34;/proc\u0026#34;, \u0026#34;proc\u0026#34;, 0, \u0026#34;\u0026#34;) // setup 완료 후 사용자 명령 실행 cmd := exec.Command(os.Args[2], os.Args[3:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { log.Fatal(err) } } /proc/self/exe는 현재 실행 중인 바이너리 자기 자신을 가리킨다.\n인자값 child를 통해 단계를 구분하고, child에서 namespace 안의 setup을 수행한 뒤 사용자 명령을 실행한다\ngo run . run sh │ ▼ ┌─────────┐ │ run │ clone(NEWUSER|NEWUTS|NEWNS|NEWPID|NEWIPC|NEWNET) └────┬────┘ │ /proc/self/exe child ▼ ┌─────────┐ │ child │ /proc/self/exe sh └─────────┘ 그림으로 보면 위와 같은 구조가 된다. pjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ go run . run sh # hostname nootainer # ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 0 22:26 pts/6 00:00:00 /proc/self/exe child s root 8 1 0 22:26 pts/6 00:00:00 sh root 10 8 0 22:26 pts/6 00:00:00 ps -ef 다시 실행해보면 이제 새로 생성된 네임스페이스 안에서 setup이 적용된 환경으로 사용자 명령(sh)이 실행된다\n파일시스템 격리 #namespace로 프로세스를 격리했지만, 아직 호스트의 파일시스템이 그대로 보인다.\n이를 해결하기 위해 setup단계에서 루트 파일시스템을 변경할 필요가 있다\n이때 사용하는 syscall이 pivot_root 이다\npivot_root는 마운트 네임스페이스 수준에서 루트 파일시스템 자체를 교체한다\nold root를 특정 디렉토리를 옮기고, new root를 / 로 만든다 그다음 old root unmount하고 삭제하면 완전히 격리된다 (다시 못들어가기 때문) func pivotRoot(rootfs string) { // rootfs를 bind mount syscall.Mount(rootfs, rootfs, \u0026#34;\u0026#34;, syscall.MS_BIND|syscall.MS_REC, \u0026#34;\u0026#34;) // 기존 루트를 임시 디렉토리로 이동 putOld := filepath.Join(rootfs, \u0026#34;put_old\u0026#34;) os.MkdirAll(putOld, 0700) syscall.PivotRoot(rootfs, putOld) // 새 루트로 이동 os.Chdir(\u0026#34;/\u0026#34;) // proc 마운트 syscall.Mount(\u0026#34;proc\u0026#34;, \u0026#34;/proc\u0026#34;, \u0026#34;proc\u0026#34;, 0, \u0026#34;\u0026#34;) // 기존 루트 해제 및 정리 syscall.Unmount(\u0026#34;/put_old\u0026#34;, syscall.MNT_DETACH) os.Remove(\u0026#34;/put_old\u0026#34;) } 이를 테스트하기 전에 pivot할 rootfs이 필요하므로 컨테이너 도구를 통해 받아오도록 한다\npjt@lima-default:~$ mkdir rootfs pjt@lima-default:~$ nerdctl export $(nerdctl create alpine) | tar -C rootfs -xf - pjt@lima-default:~$ ls rootfs bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var nerdctl 대신 docker 명령어를 사용해도 된다 이제 pivotRoot 함수를 setup 부분에 추가하여 파일시스템이 격리되는지 확인해보자\npjt@lima-default:/Users/pjt/Projects/PJT/nootainer$ go run . run /bin/sh / # ls bin dev etc home lib lib64 proc root sys tmp usr var 이제 독립된 hostname, 독립된 PID 공간, 독립된 파일시스템. sudo 없이 호스트와 격리된 환경이 만들어졌다.\n왜 Rootless여야 하는가 #전통적으로 Docker 같은 컨테이너 런타임은 root 권한으로 실행되었다. namespace, cgroup 같은 커널 기능이 root를 요구했기 때문이다.\n하지만 이는 런타임에 취약점이 있을 경우 공격자가 호스트의 root 권한을 그대로 탈취할 수 있다는 뜻이다.\n반면 rootless 컨테이너는 user namespace를 통해 이 문제를 해결한다. 런타임 전체가 일반 유저 권한으로 실행되고, namespace 안에서만 root가 된다.\n런타임에 버그가 있어도 호스트에서는 일반 유저 권한이므로 피해가 제한된다.\n다만 user namespace 자체에 대한 논란도 있다. 일반 유저가 user namespace를 만들면 커널의 root-only 코드 경로에 접근할 수 있게 되는데, 커널 코드에서 namespace root와 진짜 root의 구분을 빼먹은 부분이 있다면 namespace 탈출이 가능해진다.\n실제로 이런 류의 CVE가 보고된적이 있고, 일부 배포판에서는 user namespace를 기본 비활성화하기도 했다.\nCVE-2024-1086 — netfilter(nf_tables) use-after-free. 일반 유저가 user namespace를 만들어서 netfilter에 접근, 권한 상승. CVE-2023-0386 — OverlayFS에서 파일 copy-up 시 UID/GID 매핑 검증을 빠뜨림. user namespace의 가짜 root로 SUID 바이너리를 만들면 진짜 root 소유가 되어버림. CVE-2022-0185 — filesystem context에서 heap buffer overflow. user namespace 안의 CAP_SYS_ADMIN으로 호스트 root 탈취 + 컨테이너 탈출 그래서 rootless 컨테이너라 하더라도 namespace 격리만으로는 충분하지 않다.\n이러한 커널 공유로 인한 공격 표면을 근본적으로 줄이기 위해 gVisor나 Kata Containers 같은 프로젝트도 존재한다.\ngVisor는 사용자 공간에서 커널 syscall을 대신 처리하고, Kata Containers는 경량 VM 안에서 컨테이너를 실행하여 호스트 커널을 직접 공유하지 않는 방식을 취한다. 하지만 이는 별도의 주제이므로 여기서는 다루지 않는다. 현재 주요 컨테이너 런타임들은 rootless 모드를 지원하고 있다.\nPodman은 처음부터 rootless를 기본으로 설계되었고, Docker는 20.10 버전부터 rootless 모드를 공식 지원한다(다만 기본 설치는 여전히 root 모드). nerdctl도 rootless를 지원한다\n마무리 #unshare 를 통한 네임스페이스 실습과 Go를 통해 실제 유사컨테이너를 만들어보았다.\nnamespace와 pivot_root만으로 컨테이너 수준의 격리를 구현할 수 있었지만 아직 부족한 것들이 많다\n리소스 제한이 없다 — 컨테이너가 호스트의 CPU, 메모리를 무한히 사용할 수 있다 시스템 콜 제한이 없다 — reboot, kexec_load 같은 위험한 syscall을 호출할 수 있다 다음 글에서는 cgroup v2로 리소스를 제한하고, seccomp BPF로 시스템 콜을 필터링하여 보안을 강화해본다.\n전체 소스코드는 github에서 확인할 수 있다.\n참고\nhttps://www.youtube.com/watch?v=8fi7uSYlOdc ","date":"2026-03-22","permalink":"https://blog.opjt.dev/posts/12/","section":"posts","summary":"\u003cp\u003e처음 docker를 사용하며 컨테이너를 만들고 \u003ccode\u003eexec -it\u003c/code\u003e를 통해 컨테이너 내부에 처음 들어왔을 때 경험은 참 신기했다\u003cbr\u003e\n\u003ccode\u003eps\u003c/code\u003e 명령어를 치면 몇 안되는 프로세스들과 낯선 파일 구조들은 마치 VM처럼 느껴졌다\u003c/p\u003e","title":"컨테이너는 마법이 아니다"},{"content":"사실 나는 책으로 공부하는 걸 그다지 좋아하지 않았다.\n빠르게 변하는 IT 세상에서 두꺼운 종이 책은 어딘가에 머무르고 있다고 느껴졌기 때문이다.\n하지만 근래 《모던 리눅스 교과서》 라는 책을 읽게 되면서 이에 대한 인식이 변하게 됐는데 이유는 아래와 같다\n모르는 것은 인터넷을 검색하면 쉽게 알 수 있다 하지만 문제는 내가 뭘 모르는지 모른다는 것 이다 책은 파편화된 정보가 아니라, 체계적이고 어느 정도 맥락(Context)을 유지하는 지식을 제공한다. 후기 #《Container Security》 는 평소 관심있는 분야인 컨테이너 기술을 더 깊게 파보고 싶어서 구매하게 되었다.\n컨테이너 관련 책이면 도커 사용이나 쿠버네티스에 대한 책들이 많은데,\n나는 컨테이너의 구성 원리에 해당하는 부분들이 궁금했다\n리눅스의 namespace, cgroup 같은 개념들이 어떻게 조합되어 컨테이너를 만드는가? 컨테이너는 정말 \u0026lsquo;완벽한 격리\u0026rsquo;라고 부를 수 있는가 등등 이전에 《모던 리눅스 교과서》를 읽은 것도 컨테이너 기반 기술인 리눅스에 대해 더 알고 싶어서였다..\n책을 구입하기 전에 2021년도 발행이어서 너무 옛날 내용이진 않을까 걱정했었지만\n역시 기반기술은 그리 쉽지 변하지 않는다\n제목은 \u0026lsquo;컨테이너 보안\u0026rsquo;이지만, 초중반부에서는\nVM과 컨테이너의 차이점 — 컨테이너가 가상 머신이 아닌 격리된 프로세스에 불과하다는 점을 명쾌하게 설명한다. namespace와 cgroup을 통한 컨테이너 구성 원리 를 상세히 다룬다. 솔직히 이 기초 파트만으로도 책값이 아깝지 않다고 느꼈다.\n4장에서 다루는 네임스페이스 관련 실습은 눈으로 직접 확인하니 이해도 잘되고 재밌었다. 이후 후반에 나오는 진짜 보안에 대한 내용들은 사실 조금 느끼기 어려웠다\n아직 컨테이너를 공격적인 관점에서 다뤄보거나 깊은 장애를 겪어보지 않아서 저자의 절실함을 이해하긴 쉽지 않았다.\n그럼에도 책을 통해 저자가 바라보는 시야를 어깨너머로나마 엿볼 수 있었고, 그것만으로도 충분히 시야가 넓어진 느낌이 들었다.\n\u0026ldquo;컨테이너? 그거 완전히 격리된 거 아니야?\u0026rdquo; 라는 안일한 생각을 깨주고, 컨테이너보안에도 신경 써야 할 포인트가 많다는 걸 깨닫게 해준 저자에게 감사를 전하고 싶다.\n그리고 책을 읽고 나서 자연스럽게 드는 의문이 생겼다.\n컨테이너가 결국 호스트 커널을 공유한다면, 진짜 완전한 격리는 어떻게 구현할 수 있을까?\ngVisor처럼 시스템콜을 유저스페이스에서 가로채는 방식, Firecracker처럼 경량 microVM으로 커널 자체를 분리하는 방식, 혹은 앱에 필요한 커널 기능만 골라 단일 바이너리로 만드는 유니커널까지 — 이 질문의 답을 향한 다양한 시도들이 존재한다는 걸 알게 됐다. 이 방향이 지금은 가장 흥미롭게 느껴지는 것 같다.\n마치며 #컨테이너 환경의 인프라를 운영하거나 클라우드 생태계에 관심이 있더라면 한 번쯤 읽어보는 것을 추천한다\n컨테이너에 더 가까워진 후에 이 책을 보면 느낌이 또 다를 것 같아 소장할 생각이다\n이전에 컨테이너의 작동원리를 검색할 때 우연히 봤던 Liz Rice의 Containers From Scratch 영상을 좋게 보았더라면, 해당 책에서도 비슷한 느낌을 받을 수 있다\n","date":"2026-03-15","permalink":"https://blog.opjt.dev/posts/11/","section":"posts","summary":"\u003cp\u003e사실 나는 책으로 공부하는 걸 그다지 좋아하지 않았다.\u003cbr\u003e\n빠르게 변하는 IT 세상에서 두꺼운 종이 책은 어딘가에 머무르고 있다고 느껴졌기 때문이다.\u003c/p\u003e","title":"《Container Security》 Liz Rice"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/argocd/","section":"tags","summary":"","title":"ArgoCD"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/gitops/","section":"tags","summary":"","title":"GitOps"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/k8s/","section":"tags","summary":"","title":"K8s"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/torchi/","section":"tags","summary":"","title":"Torchi"},{"content":"배포가 귀찮아졌다 #사실 처음부터 거창하게 쿠버네티스(k8s)를 사용하려 했던 것은 아니었어요\n토치 규모의 서비스는 아직도 docker compose 만으로도 충분하다고 생각해요\n하지만 개인적으로 배포가 쉽고 간편해야 빠르게 사용해보고,\n그만큼 빠른 피드백으로 더 좋은 서비스가 될 수 있다고 생각하고 있어요\n그래서 스스로 이건 오버엔지니어링이다 라고 인정하면서도, k3s와 ArgoCD를 통해 GitOps를 구축하게 되었어요.\n이번 글에는 ArgoCD로 구축한 GitOps 환경 위에 토치 인프라가 어떻게 구성되어 있는지 이야기 해보려 해요\n이 글은 제가 만든 알림 서비스 토치를 활용하는 방법에 집중하고 있습니다.\n토치가 어떤 서비스인지, 왜 만들었는지 궁금하시다면 이전글: 토치 개발기를 먼저 읽어보시는 것을 추천드려요\n토치쿤 사실 나도 GitOps를 해본 적이 없어· · · #제가 관리하는 토치 인프라 레포(k8s-ssot)의 전체 구조예요.\n이름 그대로 Single Source of Truth(단일 진실 공급원), 즉 이 레포가 곧 서버의 현재 상태를 의미하죠.\nk8s-ssot/ ├── root-app.yaml # ArgoCD 진입점 ├── apps/ # Application 정의 │ ├── torchi-app.yaml # API + Frontend │ ├── torchi-db.yaml # PostgreSQL │ ├── torchi-infra.yaml # cert-manager 등 │ └── torchi-notifications.yaml └── manifests/ # 실제 k8s 매니페스트 ├── app/ ├── db/ ├── infra/ └── notifications/ 인프라의 수정사항은 모두 해당 레포를 통해서만 이루어지도록 의도했어요\n한 번만 등록하면 끝, App of Apps 패턴 #여기서 저는 ArgoCD의 App of Apps 패턴을 사용했어요, 하나의 상위 앱(root-app)이 여러 개의 하위 앱(applications)을 관리하는 구조예요.\n# root-app.yaml spec: source: repoURL: git@github.com:opjt/torchi-infra.git path: apps/ # 해당 경로에 있는 어플리케이션들 등록 syncPolicy: automated: prune: true selfHeal: true root-app.yaml 하나만 클러스터에 등록해두면, 나머지는 ArgoCD가 알아서 처리해요.\ntorchi-root 밑에 여러 어플리케이션이 있는 모습 apps/ 디렉토리에 새로운 Application yaml을 추가하면 ArgoCD가 알아서 인식하고 배포해요\n반대로 파일을 삭제하면 클러스터에서도 사라지고요\nDB를 K8s에 올려도 괜찮을까요? #사실 K8s를 구축하면서 가장 많이 망설였던 부분이 바로 \u0026ldquo;DB를 쿠버네티스 안으로 가져오는 게 맞을까?\u0026rdquo; 하는 점이었어요.\n쿠버네티스는 본질적으로 언제든 파드(Pod)를 죽이고 새로 만드는 Stateless한 환경이잖아요.\n하지만 DB는 데이터라는 상태를 유지해야 하는 Stateful한 성격을 지니고 있기 때문에\n자칫 파드가 재시작되면서 데이터가 날아가거나, 볼륨 연결이 꼬여버리는 대참사가 일어날까 봐 걱정이 많았습니다\n하지만 주변에서 이런 조언을 듣고 생각이 완전히 바뀌었어요.\n\u0026ldquo;쿠버네티스를 너무 어렵게 보지 말고 그냥 \u0026lsquo;똑똑한 스케줄러\u0026rsquo; 관점에서만 봐라. local 타입 PV를 쓰면 복잡한 외부 스토리지 설정 없이도 DB 관리하기 의외로 편하다.\u0026rdquo;\n쿠버네티스가 \u0026ldquo;이 DB 파드는 데이터가 들어있는 이 노드(Local PV)에서만 띄우면 돼\u0026rdquo;\n라고 스케줄링만 해준다면, 관리 난이도는 확 내려가면서도 데이터의 안정성은 챙길 수 있게 되는 거죠.\n쿠버네티스는 믿지만 사람을 믿는 것은 쉽지않아서, 언급한 것처럼 DB 전용 앱은 설정을 조금 다르게 가져갔어요.\nDB는 안된다 ## apps/torchi-db.yaml syncPolicy: automated: prune: false # 실수로 삭제 방지 selfHeal: true prune: false면 Git에서 파일이 사라져도 클러스터의 리소스는 삭제하지 않아요 실수로 DB yaml을 날려도 StatefulSet이 살아있게 하고 싶었어요 이런 부분이 App of Apps 구조의 장점이기도 해요.\nArgoCD의 syncPolicy는 App 단위로 적용되는 설계라, DB처럼 삭제에 민감한 리소스는 아예 별도 App으로 분리해두면 설정이 명확해져요.\n배포 흐름 #물론 이렇게 argoCD만 설정한다고 해서 GitOps가 완성되는 건 아니에요\n지금까지 설정들이 CD(지속적 배포) 역할을 맡았더라면,\n그 앞단에서 코드를 빌드하고 배포의 트리거가 되는 CI(지속적 통합) 역할은 Github Actions가 담당하고 있어요\n전체적인 흐름은 이런 느낌이에요.\n코드 push: main브렌치에 push될 경우 트리거가 발동해요 빌드 \u0026amp; 이미지 생성: Github Actions에서 컨테이너라이징하여 이미지를 생성해요 태그 지정: 이때 이미지 태그는 커밋 SHA를 사용하여 고유하게 관리합니다. infra-repo 업데이트: 빌드가 성공하면 인프라 레포지토리에 직접 접근하여 kustomization.yaml 파일의 이미지 태그를 새 커밋 SHA로 수정하고 커밋/푸시 해요 ArgoCD 동기화 : 인프라 레포의 변화를 감지하여 클러스터의 파드를 업데이트해요 아래는 실제 github actions에서 사용하는 인프라 레포지토리의 이미지 태그를 업데이트 하는 부분이에요\nkustomize edit 명령어를 통해 이미지 태그만 쉽게 수정할 수 있어요\n- name: Update K8s Manifest run: | cd k8s-ssot/manifests/app kustomize edit set image opjt/torchi-frontend=opjt/torchi-frontend:${{ env.IMAGE_TAG }} git config --global user.name \u0026#34;github-actions[bot]\u0026#34; git config --global user.email \u0026#34;github-actions[bot]@users.noreply.github.com\u0026#34; git add . git commit -m \u0026#34;deploy: update frontend image to ${{ env.IMAGE_TAG }}\u0026#34; git push 이렇게 인프라 레포의 git log만 보면 언제 어떤 버전이 배포됐는지 확인 가능한 형태가 되었어요\n배포 된거 맞나요? #GitOps를 적용하고 나서 배포 자체는 편해졌는데, 한 가지 불편한 점이 있었어요 push하고 나면 ArgoCD UI에 들어가서 sync 상태를 직접 확인해야 했거든요\n배포가 잘 됐는지, 언제 끝났는지 알려주는 게 없으니 습관적으로 ArgoCD 대시보드를 열어보게 되더라고요\n보통은 Slack이나 Discord로 알림을 보내는데, 저는 토치로 보내기로 했습니다\n토치가 토치의 배포를 알려준다\nArgoCD Notifications는 webhook을 지원하고,\n토치는 HTTP 요청 하나로 push 알림을 보낼 수 있어요 이 둘을 연결하면 끝이에요\n# argocd-notifications-cm.yaml # 1. 토치를 webhook 서비스로 등록 service.webhook.torchi: | url: https://torchi.app/api/v1/push/$torchi-push-channel headers: - name: Content-Type value: text/plain # 2. 언제 보낼지 trigger.on-deployed: | - when: app.status.operationState.phase in [\u0026#39;Succeeded\u0026#39;] and app.status.health.status == \u0026#39;Healthy\u0026#39; send: [deploy-message] # 3. 뭘 보낼지 template.deploy-message: | webhook: torchi: method: POST body: | [{{.app.metadata.name}}] 배포 완료 {{(call .repo.GetCommitMetadata .app.status.operationState.syncResult.revision).Message}} sync가 성공하고 Pod이 Healthy 상태가 되면 알림을 보내요 Pod이 아직 뜨는 중이면 알림이 안 가고, 실제로 서비스가 정상 동작하는 시점에 알려주는 거죠\n처음에는 단순하게 \u0026ldquo;배포 완료\u0026quot;만 보냈었는데 어떤 서비스가 어떤 버전으로 배포된 건지 알 수가 없더라고요\n그래서 GetCommitMetadata로 커밋 메시지를 포함하도록 바꿨어요 이제 알림이 이렇게 와요\n배포가 완료되면 어떤 커밋으로 배포됐는지 폰에서 바로 확인할 수 있게 됐어요\n마무리하며 #GitOps를 적용하고 나서 배포가 정말 편해졌어요 kubectl 칠 일이 거의 없어졌고, main 브렌치에 푸시만하면 바로 배포가 돼요\n글의 시작에서 이번 작업이 \u0026lsquo;오버엔지니어링\u0026rsquo;이라고 말씀드렸지만, 사실 이건 토치 프로젝트를 시작한 가장 큰 이유이기도 해요.\n쿠버네티스를 직접 사용하며 느끼고, GitOps를 구축해 보며 \u0026lsquo;어떻게 하면 더 견고하고 좋은 서비스를 만들 수 있을까\u0026rsquo;를 몸소 체험하고 싶었거든요.\n인프라가 탄탄해지고 배포에 들어가는 심리적 에너지가 줄어드니, 그 여유가 고스란히 서비스의 본질을 고민하는 시간으로 돌아오는 걸 느껴요.\n결국 기술을 공부하는 이유도, 인프라를 복잡하게 구성한 이유도, 사용자에게 더 좋은 경험을 더 빠르게 전달하기 위함이 아니었을까요?\n무엇보다 이 모든 과정 속에 토치가 작은 역할들을 해내고 있다는 게 가장 재밌는 것 같아요.\n제가 만든 서비스가 다른 서비스들과 어우러져 하나의 시스템이 되는 걸 보면 나름 괜찮은 거 같아요\n","date":"2026-03-15","permalink":"https://blog.opjt.dev/posts/10/","section":"posts","summary":"\u003ch2 id=\"배포가-귀찮아졌다\" class=\"relative group\"\u003e배포가 귀찮아졌다 \u003cspan class=\"absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100\"\u003e\u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700\" style=\"text-decoration-line: none !important;\" href=\"#%eb%b0%b0%ed%8f%ac%ea%b0%80-%ea%b7%80%ec%b0%ae%ec%95%84%ec%a1%8c%eb%8b%a4\" aria-label=\"앵커\"\u003e#\u003c/a\u003e\u003c/span\u003e\u003c/h2\u003e\u003cp\u003e사실 처음부터 거창하게 쿠버네티스(k8s)를 사용하려 했던 것은 아니었어요\u003cbr\u003e\n토치 규모의 서비스는 아직도 \u003ccode\u003edocker compose\u003c/code\u003e 만으로도 충분하다고 생각해요\u003c/p\u003e","title":"토치의 인프라 속 토치 활용기"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/go/","section":"tags","summary":"","title":"Go"},{"content":"또 웹서비스야? #또 또 또 웹서비스를 만들고 싶었던 건 아니에요\n가장 큰 이유는 두가지가 있는데,\n개인 관심사인 k8s 생태계에 익숙해지기 위해 운영할 서비스가 필요 웹서비스가 아닌 다른 종류의 서비스도 개발해보고 싶었기 때문 크롤링 시스템이든 자동화 워크플로우 든 간에 결과를 받을 창구가 필요했어요\n웹은 하고 싶진 않았지만, 쓰기 편하고 인터페이스가 간단하다는 건 부정할 수 없었어요\n그래서 결국 나만의 알림 서비스를 만들기로 했습니다\n슬랙이나 디스코드 같은 메신저 서비스에 얹을 수도 있겠지만, 원하는 기능들을 맘대로 추가하기엔 제한들이 있었기에 직접 만들게 되었습니다\n토치 바로가기 -\u0026gt; https://torchi.app\n토치가 뭔데? #토치(Torchi)는 엔드포인트 기반 push 알림 서비스예요.\nHTTP 요청 하나로 내 폰에 알림을 보낼 수 있어요.\ncurl \u0026#34;https://torchi.app/api/v1/push/7VMorfkaBM0\u0026#34; -d \u0026#39;배포 완료\u0026#39; 근데 단순히 알림만 보내고 받는 것은 다른 서비스에서도 그대로 제공하는 기능이었어요 (ntfy.sh 등등)\n그래서 토치는 알림에 수락 / 거절과 같은 액션을 붙일 수 있어요\nreaction=$(curl -s \u0026#34;https://torchi.app/api/v1/push/7VMorfkaBM0/ask\u0026#34; \\ -d \u0026#39;msg=프로덕션 배포할까요?\u0026#39; -d \u0026#39;actions=승인,거절\u0026#39;) if [ \u0026#34;$reaction\u0026#34; = \u0026#34;승인\u0026#34; ]; then ./deploy.sh fi 위는 토치를 활용한 간단한 워크플로우 형태에요\n자동화 워크플로우가 나한테 물어보고, 내가 답하면 다음 단계가 실행되는 구조예요.\n이 때부터 토치가 단순 알림시스템이 아니라\n나와 시스템이 대화가 되는 서비스가 되어야 겠다고 느꼈어요\n요즘 AI를 활용한 자동화 시스템을 많이 구축하잖아요?\n그런 곳에서도 토치를 활용해서 Human-in-the-Loop 개입이 될 수 있지 않을까 생각이들어요\n나부터 쓴다 dogfooding #토치를 디자인할 때, 다른 유저의 편의성도 있겠지만 일단 내가 먼저 쓰기 편해야 겠다고 생각했어요\n그리고 내가 직접 쓰냐는 거 이기도 했구요\n토치의 배포 파이프라인에도 토치가 쓰이고 있는데요.\n현재 torchi의 배포 시스템은 GitOps로 구축되어 있어, main 브렌치에 푸시되면 prod에 배포되고 있어요\nmain 브렌치에 푸시되면 Github Actions가 돌면서, 토치의 /ask api로 먼저 저에게 물어봐요.\n그러면 제 폰에 알림이 오게되고 승인 버튼을 누르면 이후 jobs 들이 실행돼요\n거절을 누르거나 시간안에 버튼을 누르지 못할 경우 파이프라인은 거기서 멈춰요.\n실제 github workflow는 여기서 확인할 수 있어요\n자동화는 하되, 결정은 사람이 개입하게 되는 거죠.\n토치를 쓰는 가장 대표적인 이유예요\n마무리 #토치는 거창한 아이디어가 있었던 게 아니라,\n누구나 생각할 수 있는 아이디어를 \u0026lsquo;내가 불편해서, 나에게 맞게\u0026rsquo; 구현한 서비스예요\n요즘 AI 발전으로 SaaS 제품들이 설 자리를 잃어가고 있는 것 같아요 AI로 금방 자기만의 스타일로 만들면 되니까요, 토치도 하루이틀이면 만들 수 있을 거예요.\n근데 제가 필요한 기능들을 생각하고, 불편과 필요의 의해 만든 거라 다른 서비스와는 애착이 달라지게 된 것 같아요 AI가 비슷한 걸 뚝딱 만들어줄 수는 있어도, 제가 만들었다는 건 바뀌지 않으니까요. 이 서비스를 개발하면서 했던 고민들과 경험은 대신해줄 수 없지 않을까요?\n제가 쓸 게 없어서 만든 거라\n기준이 명확하고, 피드백도 빨라서(= 내가 바로 씀) 금방금방 만들 수 있었던 것 같아요\n(사실 로고 만드는 데 시간이 가장 많이 든것 같아요😅)\n아직 부족한 부분도 많고, 해결해야 될 문제와 다듬어야 할 것도 많지만\n다중 디바이스에서 알림처리 멀티노드에서 SSE 처리 등 일단 저는 매일 쓰고 있고. 그걸로 충분한 시작이라고 생각해요.\n","date":"2026-03-02","permalink":"https://blog.opjt.dev/posts/9/","section":"posts","summary":"\u003ch2 id=\"또-웹서비스야\" class=\"relative group\"\u003e또 웹서비스야? \u003cspan class=\"absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100\"\u003e\u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700\" style=\"text-decoration-line: none !important;\" href=\"#%eb%98%90-%ec%9b%b9%ec%84%9c%eb%b9%84%ec%8a%a4%ec%95%bc\" aria-label=\"앵커\"\u003e#\u003c/a\u003e\u003c/span\u003e\u003c/h2\u003e\u003cp\u003e또 또 또 웹서비스를 만들고 싶었던 건 아니에요\u003cbr\u003e\n가장 큰 이유는 두가지가 있는데,\u003c/p\u003e","title":"내가 필요해서 만든 서비스 - 토치 개발기"},{"content":"Go를 사용하는 이유 중 가장 큰 이유는 마스코트가 귀여운 것도 있지만,\n고루틴을 사용하여 프로그래밍의 동시성을 쉽게 구현할 수 있기 때문이다\nGo Team에서 잘 만들어 준 기능은 개발자는 잘 사용하면 되지만 Go를 좋아하는 사람 입장으로서\nGo를 전도할 때\n고루틴이 뭐에요?\n정도에 대답할 수 있는 정도는 되야하지 않겠는가?\n고루틴? #goroutine은 go런타임이 사용자 공간에서 관리(스케줄링)하는 실행 단위이다.\n고루틴 특징으로는 OS 스레드와 달리 2KB 로 매우 작은 크기를 지닌다고 알려져 있는데\n초기init 스택이 매우 작게 시작하며(보통 수 KB), 함수 호출이 깊어지면 스택 크기도 증가한다.\n고루틴의 특징으로는 다음과 같다.\nGo 런타임이 고루틴의 생명주기와 스케줄링을 관리한다. M:N 스케줄링 모델을 사용하여 다수의 고루틴을 제한된 OS 레벨 스레드에 매핑함으로써, 스레드 생성 및 컨텍스트 스위칭 비용을 줄인다. Go Runtime Scheduler #런타임 스케쥴러는 Go 프로그램 실행되는 시점에 같이 실행되며, goroutine 을 효율적으로 스케쥴링 하는 역할을 지닌다.\nGMP 모델 #GMP 는 Go 런타임 스케줄러 핵심 구조이다.\n간단하게 G.M.P 에 알아보면\nG(Goroutine): Go 코드가 실행되는 최소 단위로 고루틴을 말함. g Struct M(Machine): 워커 스레드, 실제 OS 스레드이다. G가 실행되기 위해서는 M머신이 필요하다 m Struct P(Processor): 실행에 필요한 자원(context)를 지닌 추상적인 프로세서이다. P는 G고루틴를 M머신에 연결해주는 역할을 함. 최대 GOMAXPROCS 개수 만큼 지닐 수 있다. 실행 환경의 논리 CPU 개수를 기본값으로 한다. (GOMAXPROCS 환경변수를 통해 설정할 수 있음) p Struct P가 있어야 반드시 M이 일을 하고 G는 반드시 P를 통해 M에서 실행된다 고루틴은 어떻게 스케줄링되는가? #go f()를 호출하면 컴파일 타임에\nruntime.newproc 호출로 변환된다.\n// Create a new g running fn. // Put it on the queue of g\u0026#39;s waiting to run. // The compiler turns a go statement into a call to this. func newproc(fn *funcval) { gp := getg() pc := sys.GetCallerPC() systemstack(func() { newg := newproc1(fn, gp, pc, false, waitReasonZero) pp := getg().m.p.ptr() runqput(pp, newg, true) if mainStarted { wakep() } }) } https://go.dev/src/runtime/proc.go 참고\n해당 코드를 살펴보면 runqput 함수를 통해 고루틴을 넣어주는 것을 볼 수 있다.\nruntime/proc.go의 모든 소스코드를 분석할 수도 있겠지만, 아는 척 하기 위해서 흐름만 파악하도록 한다\n// runqput tries to put g on the local runnable queue. // If next is false, runqput adds g to the tail of the runnable queue. // If next is true, runqput puts g in the pp.runnext slot. // If the run queue is full, runnext puts g on the global queue. // Executed only by the owner P. func runqput(pp *p, gp *g, next bool) { ... } runqput 함수의 주석을 살펴보면 실행 가능한 local queue에 고루틴을 넣는다는 것을 알 수 있다.\nLRQ와 GRQ #LRQ (Local Run Queue) #LRQ는 P가 실행 가능한 G를 모아놓은 Queue다.\nP의 구조체를 살펴보면\n저장할 수 있는 큐 형태가 있는 것을 볼 수 있다.\ntype p struct { //... 생략 runqhead uint32 runqtail uint32 runq [256]guintptr //최대 256개 } GRQ (Global Run Queue) # LRQ가 가득 찼을 때 사용는 전역 큐 모든 P가 접근 가능. 왜 LRQ가 필요할까? #만약 LRQ가 존재하지 않고 GRQ만 사용했다면, 모든 P가 GRQ에 접근하게 된다 이럴 경우\n공유자원을 보호하기 위해 mutex를 사용하게 되고 g 생성, g 스케줄링, 컨텍스트 스위칭을 할 때마다 lock,unlock이 반복되어 성능 저하 cache locality(캐시 지역성) 효율 떨어짐 P1이 가져온 고루틴이 P2에서 놀던 놈이면 P1이 있는 CPU 코어의 캐시에는 이 고루틴이 필요로 하는 데이터가 없기 때문. 결국 스케줄링 비용이 작업 비용보다 커짐 스케줄러 세부 동작 #runnext: 바로 실행시키는 고루틴 #P마다 존재하는 LRQ는 일반적인 큐인 것처럼 보이지만 내부 로직을 살펴보면 proc.go/runcqput\nfunc runqput(pp *p, gp *g, next bool) { // 생략 ... if next { retryNext: oldnext := pp.runnext if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) { goto retryNext } if oldnext == 0 { return } // Kick the old runnext out to the regular run queue. gp = oldnext.ptr() } } next 조건에 따라 들어온 고루틴을 일반 대기열(LRQ)의 맨 뒤가 아니라 특별한 곳(runnext)으로 이동 시켜버리는 로직이 있다\n이는 가장 최근에 들어온 고루틴이 가장 높은 실행 우선순위를 받는 로직으로\nGo 스케줄러가 이 고루틴은 바로 실행시키는 것이 성능상 이점(지역성 캐시)이 있을 경우 실행된다.\n예를 들어 고루틴 A가 고루틴 B를 생성하는 경우, B는 A가 다루던 메모리 영역을 공유하거나 이어서 사용할 확률이 높기 때문(캐시 히트 높아짐) 실제로 아래 코드를 실행시켜보면 9가 먼저 출력되고 0부터 8까지 출력되는 것을 확인할 수 있다.\nfunc main() { runtime.GOMAXPROCS(1) // p를 1로 제한합니다. wg := \u0026amp;sync.WaitGroup{} for i := range 20 { wg.Add(1) go func(i int, t time.Time) { fmt.Println(i, t.String()) wg.Done() }(i, time.Now()) } wg.Wait() } # 실행 결과. 9 2026-02-08 16:13:46.34096 +0900 KST m=+0.000244043 0 2026-02-08 16:13:46.340905 +0900 KST m=+0.000189043 1 2026-02-08 16:13:46.340951 +0900 KST m=+0.000234584 2 2026-02-08 16:13:46.340952 +0900 KST m=+0.000235376 3 2026-02-08 16:13:46.340957 +0900 KST m=+0.000240126 4 2026-02-08 16:13:46.340957 +0900 KST m=+0.000240876 5 2026-02-08 16:13:46.340958 +0900 KST m=+0.000241251 6 2026-02-08 16:13:46.340958 +0900 KST m=+0.000241709 7 2026-02-08 16:13:46.340959 +0900 KST m=+0.000242918 8 2026-02-08 16:13:46.34096 +0900 KST m=+0.000243501 work stealing #Go 스케줄러는 P마다 LRQ를 가지고 대부분의 고루틴을 처리한다.\n하지만 P1은 LRQ가 비어있고, P2는 가득차있다면?\n똑똑이 스케줄러는 바로 P2의 고루틴을 도둑질하여 도와준다.\nproc.go/runqsteal\nP A (idle) ↓ findRunnable ↓ stealWork ↓ for each P B ↓ runqsteal ↓ B의 LRQ 절반 가져옴 A의 LRQ에 push findRunnable 함수를 확인해보면 스틸 로직 외에도 효율적으로 고루틴을 돌리기 위한 로직들을 살펴볼 수 있다.\nSyscall block 과 handoff #고루틴이 CPU Bound 작업만 한다면 스케줄링은 비교적 단순하겠지만, 고루틴을 사용하는 상황은 그렇게 호락호락하지 않다\n보통은 파일 읽기 같은 시스템 콜(syscall)을 호출하게 되면 그 고루틴을 실행하던 M머신이 함께 블락된다\n이럴 때 스케쥴러는 P의 다른 고루틴들이 블락되는 것을 방지하기 위해 M1과 잡고있는 G를 함께 분리(handoff)시켜버린다.\nP는 M1이 없어졌기 때문에 스케쥴러는 idle상태인 다른 M2을 찾아 P에게 쥐어준다\nidle상태인 M이 없을 경우 새롭게 생성시킴 이후 시스템 콜을 마치고 돌아오면 (proc.go/exitsyscall, 참고)\n원래 쓰던 P가 있으면 바로 재결합. 없으면 G를 스케줄러에게 위임한다 이 때문에 시스템 콜 블로킹이 발생하면 1개의 고루틴마다 1개의 스레드를 찍어낸다.\nNetpoller #위에서 설명한 시스템 콜 방식대로면, 수만 개의 동시 접속을 처리하는 웹 서버는 수 만개의 스레드(M)을 생성해야 하고\n결국 메모리 부족(OOM)으로 터지게 될 것이다.\ngo에서 발생시킬 수 있는 스레드는 최대 만개이다. sched.maxmcount 하지만 Go는 그렇게 허술하지 않다 이때 사용하는 것이 Netpoller이다\n고루틴이 네트워크 IO를 시도하면, 일반 시스템콜 처럼 머신을 통째로 블락시키지 않는다\n대신 해당 고루틴(G)는 Netpoller 라는 별도의 공간에 분리되고, M은 즉시 다른 G를 실행하러 떠난다.\n이때 Netpoller는 내부적으로 OS의 비동기 I/O 이벤트 알림(epoll(Linux), kqueue(BSD), iocp(Window) 등 )을 사용한다\n핵심 원리는 응답이 오면 이벤트를 보내 스케쥴러가 다시 G를 데리고 갈 수 있도록 한다. func main() { c := make(chan bool) for i := 0; i \u0026lt; 1000; i++ { go func(c chan bool) { fmt.Println(\u0026#34;block() enter\u0026#34;) var s1 string _, _ = fmt.Scan(\u0026amp;s1) c \u0026lt;- true }(c) } for i := 0; i \u0026lt; 1000; i++ { _ = \u0026lt;-c } } 따라서 위에 코드를 실행해도 스레드는 12개 밖에 사용하지 않는다.\n마무리 #그 외에도 스케줄링 공평성을 위해 61번째마다 GRQ를 읽는 로직이라던지, 너무 오래실행되는 고루틴을 선점하는 로직 등\n고루틴 박사가 되기 위해선 하루이틀로는 부족하다.. 하지만 위에 있는 내용들만으로도 충분히 아는 척은 할 수 있으리라 생각된다.\n참조 # https://github.com/golang/go https://ykarma1996.tistory.com/188 - 고루틴의 동작 원리에 관하여 https://dingyu.dev/posts/gmp/#netpoller-m - 고루틴 1억 개 돌려도 괜찮을까? https://changhoi.kim/posts/go/go-scheduler/ - Go Scheduler https://www.youtube.com/watch?v=wQpC99Xu1U4 - [GopherCon 2021: Queues, Fairness, and The Go Scheduler - Madhav Jivrajani] https://ssup2.github.io/blog-software/docs/theory-analysis/golang-goroutine-scheduling/ https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html 본 글은 Go 1.26 버전을 기준으로 작성되었습니다\n","date":"2026-02-08","permalink":"https://blog.opjt.dev/posts/8/","section":"posts","summary":"\u003cp\u003eGo를 사용하는 이유 중 가장 큰 이유는 마스코트가 귀여운 것도 있지만,\u003cbr\u003e\n고루틴을 사용하여 프로그래밍의 동시성을 쉽게 구현할 수 있기 때문이다\u003c/p\u003e","title":"고루틴 아는 척 하기"},{"content":"요즘 AI 발전에 주변에서 말이 참 많은 것 같다\n바이브 코딩으로 얼마를 벌었다\n더이상 신입 개발자 자리가 없다\n개발자는 없어진다 등등\n솔직히 LLM이 나보다 아는 지식도 월등히 많고, 코드 짜는 것도 잘한다\n그렇다고 인간의 뇌를 지닌 나로서는 패배를 인정하고 허무함만 느낄 수는 없지 않나?\n그래서 나는 더 이상 개발자로 살아남기 위해 뭘 해야 되는지를 생각하는 것이 아니라\n그냥 원래 처음 개발을 접했던 이유처럼 좋아하는 걸 하기로 결심했다.\n하는 게 앞으로 의미 없어질까 두려워, 뭘 해야 될지 이리저리 각 보느라 시간 태우느니\n차라리 just do it 이라 생각되는 요즘이다.\n","date":"2026-02-05","permalink":"https://blog.opjt.dev/posts/7/","section":"posts","summary":"\u003cp\u003e요즘 AI 발전에 주변에서 말이 참 많은 것 같다\u003c/p\u003e\n\u003cp\u003e바이브 코딩으로 얼마를 벌었다\u003cbr\u003e\n더이상 신입 개발자 자리가 없다\u003cbr\u003e\n개발자는 없어진다 등등\u003c/p\u003e","title":"just do it"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/%EC%9E%A1%EB%85%90/","section":"tags","summary":"","title":"잡념"},{"content":" 사실 거창한 기여를 한 건 아니다. 하지만 \u0026ldquo;첫\u0026rdquo; 기여라는 점에서, 이 경험은 꼭 한 번 기록으로 남기고 싶었다.\n기여한 지는 좀 됐는데, 릴리즈되기 전까지는 혹시 모를 상황이 있을까 봐 글 쓰는 걸 미루고 있었다.\nnerdctl을 아시나요 #아는 사람도 있겠지만, 나는 기여하기 전까지는 nerdctl을 잘 몰랐다.\n간단히 말하면 docker를 쓰지 않고 containerd와 직접 통신하기 위해 사용하는 CLI 도구라고 보면 된다.\n평소에 CNCF나 컨테이너 기반 시스템에 관심이 많아서, 언젠가는 클라우드 생태계의 오픈소스에 꼭 한 번 기여해보고 싶다는 생각을 하고하고있었다.\n마음 같아선 containerd에 기여하고 싶었지만,,,\n솔직히 말해 그건 아직 나한테 너무 높은 산처럼 느껴졌다.\n그러다 nerdctl을 보게 됐는데, 오픈소스 초보자를 위한 good first issue 태그가 잘 정리돼 있었다.\n아 이거다.\n(Thank you, nerdctl team!)\n제가 해도 될까요? #첫 오픈소스 기여다 보니, 이슈를 고르고 작업에 이르기까지 조차 쉽지 않았다.\n그러다 눈에 들어온 이슈가 있었는데,\n#3851 \u0026lt;\u0026ndash; 이슈 포착\ngoot first issue 태그가 달린 것 중에 할당이 안된 이슈를 찾고 어느정도 사이즈가 나오면\n정중히 여쭤봅니다.\n챗 지피티님. 제가 이 이슈를 작업할 수 있게 도와주세요.\n이후 이슈에 코멘트를 달고 Assignees에 매핑된 걸 확인하고 나서야 작업을 시작했다\n첫 PR # nerdctl에는 docker와는 다르게 namespace라는 개념이 있다 기존 namespace 명령어에 유효성 체크하는 로직들이 부족하여\n특정 플래그(\u0026ndash;label) 필수로 지정하도록 수정 update 명령어 실행 시 namespace 존재하는지 체크 등 그리고 namespace inspect 명령어 부분에서 namespace에 속한 리소스 정보들을 포함하도록 개선하였다\nPR#4618\n올리고 며칠 기다리니 리뷰어님의 폭풍 피드백을 받게 됐다. 좋은 피드백도 있었지만, 내 코드를 제대로 보지 않고 한 피드백도 있어 당시에는 이해가 되지 않았다.\n(내가 기존 코드를 잘 못 이해했나 싶어서 몇시간을 뒤적이던게 생각난다)\n(지금 다시 보면 웃기지만, 서로가 서로를 답답해하고있다는 게 느껴진다)\n그래도 다행히 이야기를 계속 나누다 보니, 결국 리뷰어 분도 본인이 잘못 본 부분이 있었다는 걸 인정해 주셨다.\n다만 이 과정에서 시간을 가장 많이 쏟았던 부분(inspect 명령어에서 리소스 정보를 추가한 변경 사항)은 아쉽게도 이번 PR에서는 제외됐다.\n이유는 명확했다. 이 변경이 inspect 커맨드를 너무 무겁게 만든다는 메인테이너님의 판단이었다. 솔직히 맞는 말이기도 하고, 무엇보다 AI에 지배당하는 내 영어 실력으로는 끝까지 설득할 자신이 없었다.\n그래서 미련없이 PR을 닫고 유효성 검증 로직만 깔끔하게 정리해서 다시 PR을 올렸다.\nPR#4631\n새로 올린 PR은 누가봐도 논란의 여지가 없었고 너무 간단한 부분들이었기에 별 탈 없이 승인이 되었다.\n마치며 #조금의 우여곡절이 있었지만\nMerged의 보랏빛 아이콘이 주는 쾌감은 나름 좋았다\n첫 기여를 하며 느꼈던 점은, 오픈소스 기여는 단순히 코드만 잘 짠닥 되는 게 아니라는 점이다. 메인테이너와 기존 기여자들이 어떤 의도로 코드를 작성했는지 이해해야 하고, 내가 왜 이런 변경을 했는지를 설명할 수 있어야 하며,\n때로는 적당히 타협할 줄 아는 커뮤니케이션 능력도 정말 중요했다.\n생각해 보면 회사에서 일하는 것과 크게 다르지 않다. 그래서 왜 오픈소스 기여 경험을 중요하게 보는지도 조금은 알 것 같았다.\n내가 nerdctl을 처음부터 만든 것은 아니지만,\n이제 어딘가 내 코드 한 줄이 섞여 있다는 사실만으로 우쭐해지게 되는 이 기분은 나름 나쁘지 않은 것 같다\n아마 이런 감정 때문에 사람들이 오픈소스를 하는 게 아닐까 싶다.\n내가 좋아하는 분야를 다른 사람들과 함께 만들어 가고, 내가 만든 코드가 누군가에게 실제로 쓰이고, 그 과정에서 느끼는 작은 성취감.\n이번 경험을 시작으로, 앞으로는 더 많은 프로젝트와 컨테이너 생태계에 계속해서 기여해보고 싶다.\n(그러기 위핸 영어 공부를 해야하지 않을까 싶다.)\n","date":"2026-01-01","permalink":"https://blog.opjt.dev/posts/6/","section":"posts","summary":"\u003cp\u003e\n\n\n\n\n\n\u003cfigure\u003e\n    \n    \n\n\n\n\n\n\n\n\n  \n    \u003cpicture\n      class=\"mx-auto my-0 rounded-md\"\n      \n    \u003e\n      \n      \n      \n      \n        \u003csource\n          \n            srcset=\"https://blog.opjt.dev/posts/6/1_hu_a0998698d9012e2d.webp 330w,https://blog.opjt.dev/posts/6/1_hu_1d5c0ed6ce8ab891.webp 660w\n            \n              ,https://blog.opjt.dev/posts/6/1_hu_707b3d2beeeb3633.webp 1024w\n            \n            \n              ,https://blog.opjt.dev/posts/6/1_hu_cd0ae9f3b04dfe18.webp 1320w\n            \"\n          \n          sizes=\"100vw\"\n          type=\"image/webp\"\n        /\u003e\n      \n      \u003cimg\n        width=\"2544\"\n        height=\"1252\"\n        class=\"mx-auto my-0 rounded-md\"\n        alt=\"first contribute!\"\n        loading=\"lazy\" decoding=\"async\"\n        \n          src=\"https://blog.opjt.dev/posts/6/1_hu_51b6bb0fc18134f9.png\" srcset=\"https://blog.opjt.dev/posts/6/1_hu_d7d470664dd5c87e.png 330w,https://blog.opjt.dev/posts/6/1_hu_51b6bb0fc18134f9.png 660w\n          \n            ,https://blog.opjt.dev/posts/6/1_hu_d4d5914b841eb350.png 1024w\n          \n          \n            ,https://blog.opjt.dev/posts/6/1_hu_af936469d034d3e6.png 1320w\n          \"\n          sizes=\"100vw\"\n        \n      /\u003e\n    \u003c/picture\u003e\n  \n\n\n\u003c/figure\u003e\n\n사실 거창한 기여를 한 건 아니다. 하지만 \u0026ldquo;첫\u0026rdquo; 기여라는 점에서, 이 경험은 꼭 한 번 기록으로 남기고 싶었다.\u003c/p\u003e","title":"두근두근 첫 오픈소스 기여"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/blog/","section":"tags","summary":"","title":"Blog"},{"content":"구글에 기술 관련 내용을 검색하면 velog, tistory, 네이버 그리고 ***.github.io 형태의 블로그들이 있다. 사실 구글 검색엔진에 블로그 글들이 검색이 안되는 현 시점에서, 아직도 나는 블로그 플랫폼을 고민하고 있다.\n블로그 요이땅 참고 색인이 안되는 이유는 정확히는 모르지만 나의 추측성으로는 글의 품질이 좋지 않아서 이다\n나만 그런진 모르겠는데 언젠가 공유해줄 글을 쓸 것을 대비하여 블로그는 만들어두지만, 막상 글을 쓰기까지가 어렵게 느껴진다. 어쩌면 겉멋만 들어서 좀 있어 보이는 글만 작성하려고 그런 걸지도 모르겠다.\n왜 github 블로그인가 #정확히는 정적 생성 형식의 블로그인가 라고 하는 게 맞지 않나 싶다\n크게 고민한 플랫폼은 velog, tistory 인데\n우선 tistory를 선택하지 않은 이유는 마크다운 형식의 글쓰기가 불편하다는 것이다. 뭔가 기록할 때 md형식으로 많이 쓰는 데 tistory는 그런면에서 글 작성, 아카이빙 면에서 불편하다고 느껴졌다\n그러면 왜 velog를 선택하지 않았냐,,,하면\n직접 커스터마이징이 불가능 하다는 것, 사실 이 이유 때문에 SSG 형식을 고수하고 있다. 어쩌면 너무 컨트롤적인 것에 대한 반감일 수도 있고, 독립적인 곳에 있고 싶은 영향도 큰 것 같다 지금은 GitHub으로 호스팅되고 있지만, 정적 파일을 올릴 수 있는 곳이라면 어디서든 공개할 수 있다는 점이 SSG 방식의 장점인 것 같다.\n","date":"2025-12-13","permalink":"https://blog.opjt.dev/posts/5/","section":"posts","summary":"\u003cp\u003e구글에 기술 관련 내용을 검색하면 velog, tistory, 네이버 그리고 ***.github.io 형태의 블로그들이 있다.\n사실 구글 검색엔진에 블로그 글들이 검색이 안되는 현 시점에서, 아직도 나는 블로그 플랫폼을 고민하고 있다.\u003c/p\u003e","title":"블로그 플랫폼에 대한 고민"},{"content":"내 블로그는 죽었다.\n구글 검색 엔진에 등록해 보고 몇 달이 지나도 색인이 되지 않는다.\n어쩌면 이전에 발행한 글들의 퀄리티가 낮아서 그런 것일지도 모른다는 생각이 들었다.\n남들에게 공유하고 싶어서 만든 블로그였지만, 이젠 생각을 달리 가져보려 한다.\n이 글을 쓰는 시점으로, 오늘 K리그 프로그래머라는 멋진 분의 글을 마주쳤습니다.\n아 꼭 블로그가 화려하지 않아도 되구나, 어쩜 저렇게 글을 담백하게 쓸까?\n검색엔진에 등록되어 유입되기 어려워도, 인터넷에 올라간 순간 누구든지 볼 수 있습니다.\n검색으로 찾아오는 블로그가 아니라 구독으로 찾아보게 되는 블로그를 만들고 싶습니다.\n저도 한번 따라 해 보려 합니다.\n","date":"2025-11-20","permalink":"https://blog.opjt.dev/posts/4/","section":"posts","summary":"\u003cp\u003e내 블로그는 죽었다.\u003c/p\u003e\n\u003cp\u003e구글 검색 엔진에 등록해 보고 몇 달이 지나도 색인이 되지 않는다.\u003cbr\u003e\n어쩌면 이전에 발행한 글들의 퀄리티가 낮아서 그런 것일지도 모른다는 생각이 들었다.\u003c/p\u003e","title":"나 혼자만 보는 블로그"},{"content":"사실 티켓을 사는 것 조차 고민이 있었다\n아 이거 세션 영상 어차피 유튜브에 올라오는데 꼭가야할까\n가격도 엄청 막 싸다고는 할 수 없었기 때문에 더 그랬을까\n하지만 거리도 생각보다 가깝고, 지금 아니면 언제 가볼까 싶은 마음 그리고 새로운 자극을 받을 수 있지 않을까 싶은 생각이 가장 컸다\n장소: 코엑스 마곡 4층 르웨스트홀B 귀엽게 반겨주는 고퍼\n체크인 후 굿즈(스티커, 프린팅옷, 물)를 받은 뒤 바로 홀로 들어갔다\n세션외에도 후원사 부스, 포토부스, 굿즈판매이 있었는데 참여하지는 않았다 11시부터 18시까지 동안 세션을 듣는 다는 것이(물론 점심,쉬는시간 있음) 생각보다 쉽지 않을 줄 알았는데\n세션을 듣다보니 시간이 훅훅 가버리더라\n솔직히 모든 세션의 내용을 이해한 것은 아니다 블록체인 쪽은 배경지식이 부족해서 집중이 되진 않았다\n하지만 생각 외로 많은 자극들을 느낄 수 있었던 것이\n많은 사람들 앞에서 발표하는 모습(나도저렇게pt하고싶다),\n좋아하는 언어 하나로 100명 넘게 모인 사람들,\n그리고 나는 아직 Go를 Go스럽게 쓰지 못하고 있다는 걸 느꼈다.\n올까말까 고민했던 나지만, 어쩌다 보니 클로징할 때까지 남아있게 되었고 운이좋게 경품(텀블러)도 받게 되었다(사실 뱃지 받고싶었음)\n첨엔 티켓값이 아깝다고 생각이 들었지만 집에 와서 다시 생각하니 하나도 아깝지가 않다\n이 컨퍼런스에 참여하기 위해 부산에서 까지 올라오신 분도 계셨는데 그분들은 얼마나 더 go에 진심이신 걸까,,\n만약 고퍼콘을 갈까말까 고민하다 서칭해서 이 글을 보게 되셨다면, 한번 가보십쇼 라고 전하고 싶다\n","date":"2025-11-09","permalink":"https://blog.opjt.dev/posts/3/","section":"posts","summary":"개발자 컨퍼런스 처음 가본 사람","title":"고퍼콘2025후기"},{"content":"Go로 개발할 때 환경변수를 어떻게 관리할 지 한번쯤은 고민을 해보셨을 겁니다\nNode.js 나 Python 진영에서 사용하는 방법처럼 .env 파일을 사용해서 읽어오거나\n표준 라이브러리인 os.Getenv 를 사용해서 환경변수를 다루시는 분들도 있으실 거에요\n저는 개인적으로,\nGo에서는 OS(Environment Variable)을 사용하는 것이 더 표준에 가깝다고 생각해요.\n.env 파일은 편의용으로는 괜찮지만, 운영 표준은 아니라고 봐요.\nOS 환경변수를 사용하는 이유 #Go는 운영체제와 아주 밀접하게 설계된 언어 같아요\n파일, 네트워크, 프로세스 같은 것들을 직접 다루는 표준 패키지가 준비되어 있고, 환경변수도 마찬가지로 os 패키지에서 바로 접근할 수 있습니다\nport := os.Getenv(\u0026#34;PORT\u0026#34;) if port == \u0026#34;\u0026#34; { port = \u0026#34;8080\u0026#34; } fmt.Println(\u0026#34;Server running on port:\u0026#34;, port) 별도의 설정 파일이나 외부 의존성 없이, 운영체제에서 바로 값을 받아오는 게 가장 단순하고 직관적인 방법이라고 생각해요\nOS 환경변수를 표준이라고 볼 수 있나 #운영 환경에서는 .env 파일을 직접 읽지 않아도 이미 환경변수를 관리할 수 있는 방법이 충분히 준비되어 있습니다.\n환경 설정 방식 systemd EnvironmentFile Docker \u0026ndash;env, \u0026ndash;env-file kubernetes env, envFrom 어플리케이션 코드레벨에서 .env 를 직접 읽지 않아도 운영체제나 컨테이너 레벨에서 이미 표준화된 방식으로 주입할 수 있어요.\n개발환경을 고려한 환경변수 다루기 #개발환경에서는 OS환경변수를 일일이 export 하기 번거스러워요\n이때는 godotenv 라이브러리를 사용하면 편하게 .env 파일을 읽어서 os.Getenv()로 접근할 수 있어요\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;os\u0026#34; \u0026#34;github.com/joho/godotenv\u0026#34; ) func main() { if os.Getenv(\u0026#34;APP_STAGE\u0026#34;) == \u0026#34;dev\u0026#34; { _ = godotenv.Load(\u0026#34;.env.dev\u0026#34;) } port := os.Getenv(\u0026#34;PORT\u0026#34;) dbURL := os.Getenv(\u0026#34;DB_URL\u0026#34;) fmt.Println(\u0026#34;PORT:\u0026#34;, port) fmt.Println(\u0026#34;DB_URL:\u0026#34;, dbURL) } 모든 변수를 export 하는 것 보다 APP_STAGE=dev go run main.go로 실행하면 되니 훨씬 편해졌어요^^\n","date":"2025-10-25","permalink":"https://blog.opjt.dev/posts/2/","section":"posts","summary":"어떻게 환경변수를 관리해야 잘했다고 소문이날까","title":"Go 에서 환경변수 다루기"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/neovim/","section":"tags","summary":"","title":"Neovim"},{"content":"neovim이 뭔데 # 쉽게 생각하여 우리가 cli 환경에서 자주 사용하는 vim의 진화버전이라고 보면 된다\n그렇다면 사람들은 굳이굳이 IntelliJ나 vsCode가 있는데도 neovim을 IDE로 사용하려는 것인가?\n사실 neovim이나 emacs나 둘다 마이너하다고 생각해서 남들과는 다르게 보이고 싶은 마음이 절반이라고 봄(개인적 생각)\n그럼에도 내가 단기간 사용해보면서 생각한 몇가지 장점들이 있다\n내가 생각하는 진짜 장점 #1.. 생산성 GOAT\n키보드로 모든 작업을 수행할 수 있다보니 숙련된다면 매우 높은 생산성을 만들 수 있다\n(다른 툴도 단축키 맵핑하면 똑같은 거 아님?)\n2.. 이식성\n똑같은 vim 설정을 가져가고 싶다면 .vimrc 를 복붙해서 사용하듯\nneovim도 똑같다. 설정파일을 그대로 가져온다면 내 맥이서든 대상 서버에서든 새로 산 맥북에서든 똑같은 환경에서 작업할 수 있다\n그리고 터미널에서 돌아가다보니 다른 IDE에 비해 가볍기도 함\n단점에 대해서는 충분히 사용하고 나서 작성할 예정이다\nneovim 시작하기 #neovim을 시작하는 방법은 여러 방법이 있는데\n그중 하나인 kickstart를 사용하겠습니다\nkickstart와 비슷한 형태로는 lazyvim이 있는데 lazyvim은 당장 IDE로 사용할 수 있는 환경을 제공하는 반면 kickstart는 스타터킷의 개념입니다 brew install neovim git clone https://github.com/nvim-lua/kickstart.nvim.git ~/.config/nvim 설치 방법은 위 커맨드가 끝입니다(mac 한정)\n이후 터미널에서 nvim을 입력하면 사진과 같은 화면을 보실 수 있습니다\nkickstart 구조 #~/.config/nvim ├── doc │ ├── kickstart.txt │ └── tags ├── init.lua ├── lazy-lock.json ├── LICENSE.md ├── lua │ ├── custom │ │ └── plugins │ └── kickstart │ ├── health.lua │ └── plugins # 기본 제공 플러그인 └── README.md 처음 설치하게 되면 플러그인이 비활성화되어 있습니다\nnvim ~/.config/nvim/init.lua -- require 'kickstart 를 검색하시면 주석된 플러그인 항목이 있습니다\n-- require \u0026#39;kickstart.plugins.debug\u0026#39;, -- require \u0026#39;kickstart.plugins.indent_line\u0026#39;, -- require \u0026#39;kickstart.plugins.lint\u0026#39;, -- require \u0026#39;kickstart.plugins.autopairs\u0026#39;, -- require \u0026#39;kickstart.plugins.neo-tree\u0026#39;, -- require \u0026#39;kickstart.plugins.gitsigns\u0026#39;, -- adds gitsigns recommend keymaps 여기서 사용할 플러그인 앞에 있는 -- 를 제거해 주세요\n다시 nvim을 실행 후 :Lazy 를 입력하면 설치된 플러그인을 확인할 수 있습니다\n커스텀 플러그인 추가 #직접 플러그인을 추가할 때는 custom/plugins 디렉토리를 사용합니다\nnvim 화면내에 터미널을 띄우는 toggleterm을 예제로 설명하겠습니다\n-- ~/.config/nvim/lua/custom/plugins/toogleterm.lua return { { \u0026#39;akinsho/toggleterm.nvim\u0026#39;, version = \u0026#39;*\u0026#39;, config = function() require(\u0026#39;toggleterm\u0026#39;).setup { size = 20, open_mapping = [[\u0026lt;c-\\\u0026gt;]], direction = \u0026#39;horizontal\u0026#39;, } end, }, } 플러그인을 추가했으면 init.lua 에서 custom plugin을 임포트하는 부분을 활성화해 주세요\n-- { import = \u0026#39;custom.plugins\u0026#39; }, 해당 코드의 주석을 해제하면 cusotm/plugins 디렉토리에 있는 .lua 파일들이 임포트 됩니다 마찬가지로 저장후 재실행하여 주세요\n이제 Control+\\ 를 통해 터미널을 띄울 수 있습니다\n","date":"2025-09-22","permalink":"https://blog.opjt.dev/posts/1/","section":"posts","summary":"with kickstart","title":"neovim 찍먹"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/tags/hugo/","section":"tags","summary":"","title":"Hugo"},{"content":"젠장 또 SSG 블로그야 #사실 이번이 첫 블로그는 아니다\n이전에도 Jekyll로 블로그를 만든 적이 있었지만, 글을 잘 쓰지 않게 되고 왠지 손이 잘 안 가더라.\n게다가 구글 SEO에 등록했는데도 글이 검색에 뜨지 않는 문제가 있어, 그 경험이 블로그에 대한 인상을 좋지 않게 남겼던 것 같다.\n그래서 이참에 새롭게 시작하자는 생각으로 hugo 를 통해 블로그를 다시 시작해보려 한다\nwhy hugo? #hugo는 go로 만들어졌는데 그 이유가 전부다\n최근 go에 관심을 갖고 go를 좋게 생각하기 때문이다\n테마는 https://canstand.github.io/compost/를 사용하였는데 개인적으로 뭔가 부족해 보이지만 심플해서 좋다\n부족한 부분은 조금씩 수정하면서 완성할 생각이다\ncompost의 전신이라고 판단되는 Congo로 테마를 변경하였다 블로그 목적 #회사를 다니면서 무언가를 기록하는 것이 매우 중요하고 값진 행위라는 것을 느낀다\n개인적으로 멋진 기술 글들을 보면서 나도 저렇게 글을 잘 쓰고 싶다는 생각도 든다\n뭐든 꾸준히 하는 것이 어렵다지만,,\n정말 어렵습니다 이번에는 잘 쓰지는 못해도 꾸준히를 목적으로 하려고 해요\n며칠 간 seo 부분을 해결하지 못하면 velog로 넘어갈 생각도 있다\n어찌보면 이 글은 처음이자 마지막이 될 수도 있는 글이다 ㅋㅋ\nhugo로 blog 만들기 # hugo에서 사용할 수 있는 테마는 정말 많다 https://themes.gohugo.io/\n이 수많은 테마 중 심플하면서 블로그로서의 기능을 할 수 있을 것 같은 몇 가지 테마 입니다.\nhttps://themes.gohugo.io/themes/loveit/ https://themes.gohugo.io/themes/hugo-papermod/ https://themes.gohugo.io/themes/congo/ 저는 이 목록에 없는 compost 테마를 사용하여 블로그를 만드려고 하였으나\n이의 전신인 congo 를 최종적으로 선택하게 되었습니다.\n본 글에서는 hugo의 설치방법은 따로 가이드 하지 않겠습니다.\nhugo new site #Congo install guide 를 참고하세요\nhugo로 블로그를 만드는 것은 정말 5분도 걸리지 않습니다.\nhugo new site newblog cd newblog hugo mod init newblog 원래 mod init 뒤에 오는 부분은 go 모듈 경로로 보통 자신의 레포지토리 주소를 씁니다 사실 이 부분만 가능하다면 이미 절반은 온 셈입니다\n이후 config/_default 디렉토리를 만들어 주세요.\nconfig 설정\ndownload a copy 로 파일을 받아주세요 받은 파일을 config/_default/ 경로에 넣어주세요 루트 레벨(go.mod가 있는 레벨)에 있는 hugo.toml 은 삭제하여 주세요. newblog ├── assets ├── config │ └── _default ├── hugo.toml ├── languages.en.toml ├── markup.toml ├── menus.en.toml ├── module.toml └── params.toml 최종적으로 위와 같은 형태가 되어야 합니다\nhugo server 명령어 실행 후 localhost:1313 를 접속하면 짜자잔~ 블로그를 확인하실 수 있습니다\ncongo theme setting #공식 설정 가이드 를 참고하여 주세요\n블로그처럼 사용하기 위해 최소한의 설정을 설명합니다\n. ├── config │ └── _default ├── content │ ├── _index.md # localhost:1313/ │ └── posts │ ├── _index.md # localhost:1313/posts/ │ └── tags │ ├── _index.md # localhost:1313/tags/ --- title: \u0026#34;\u0026#34; --- 위와 같은 구조로 _index.md 파일을 3개 만들어 주세요\ntitle에 들어가는 값은 각 페이지에 접근했을 때 표기될 타이틀명입니다\nconfig/_default에 있는 다양하고 디테일한 설정들은 Congo configuration 공식문서를 참고하여 주세요\nhugo new content #hugo new content posts/new_post.md 명령어로 블로그 글을 생성해 보세요\n","date":"2025-09-12","permalink":"https://blog.opjt.dev/posts/init_blog/","section":"posts","summary":"hugo로 정적 블로그를 만들어봐요","title":"블로그 요이땅"},{"content":"","date":null,"permalink":"https://blog.opjt.dev/categories/","section":"Categories","summary":"","title":"Categories"}]