> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# OCI A1에 K3s 설치하기: 기존 Docker와 NPM을 유지한 쿠버네티스 구성
- URL: https://mlog.me/oci-a1-k3s-docker-npm/
- Published: 2026-10-28T07:20:04.000Z
- Updated: 2026-10-28T07:20:04.000Z
- Description: OCI Ampere A1에 기존 Docker와 Nginx Proxy Manager를 유지한 채 K3s를 설치했다. Tailscale 기반 NodePort, swap·자원 예약, HTTPS 연결, 두 Pod의 요청 분산과 Portainer 연동까지 실제 검증 과정을 정리한다.
- Author: mLog
- Tags: 서버·인프라, Docker, Linux, Nginx Proxy Manager

오라클 클라우드의 Ampere A1 서버에서 Docker로 여러 서비스를 운영하고 있다. 블로그는 Ghost로, 외부 접속과 인증서는 Nginx Proxy Manager로 관리하는 구성이다. 여기에 쿠버네티스를 추가하면서 가장 먼저 생각한 것은 기존 서비스와 어떻게 함께 운영할지였다.

이번에는 A1 한 대에 K3s를 설치하고, 작은 웹 애플리케이션을 배포하는 것부터 시작했다. 기존 Nginx Proxy Manager를 통해 HTTPS로 접속한 뒤, 실제로 두 개의 Pod가 요청을 처리하는지까지 확인했다. 마지막에는 Portainer에도 클러스터를 연결했다.

설치 명령 하나로 끝내기보다, 각 설정을 선택한 이유와 설치 후 무엇을 확인했는지 중심으로 기록해 둔다.

> 2026년 10월 27\~28일에 진행한 구성과 검증을 기준으로 작성했다. 확인된 K3s 버전은 `v1.36.5+k3s1`이다. 본문의 Tailscale IP `100.101.102.103`과 테스트 도메인 `k3s.example.com`은 설명용 예시이며, 설정 코드는 실제 파일에서 필요한 부분을 발췌했다.

## A1 한 대에서 시작한 구성

이번에 사용한 서버 환경은 다음과 같다.

| 항목      | 구성                                        |
| ------- | ----------------------------------------- |
| 서버      | OCI Ampere A1                             |
| 운영체제    | Ubuntu 24.04 LTS                          |
| 아키텍처    | ARM64 / aarch64                           |
| CPU     | 4 CPU                                     |
| 메모리     | 24GiB                                     |
| 루트 디스크  | 약 96GB                                    |
| Swap    | 8GiB                                      |
| 쿠버네티스   | K3s v1.36.5+k3s1                          |
| 노드      | goldensupp-oci 단일 노드                      |
| 기존 서비스  | Docker 컨테이너, Ghost, Nginx Proxy Manager 등 |
| 관리 네트워크 | Tailscale                                 |

K3s는 ARM64를 지원하는 경량 쿠버네티스 배포판이다. 이번처럼 서버 한 대에서 설치와 애플리케이션 배포를 경험하려는 환경에 맞춰 선택했다. 지원 아키텍처와 네트워크 요구 사항은 [K3s 요구 사항 문서](https://docs.k3s.io/installation/requirements?ref=mlog.me)에서 확인할 수 있다.

같은 서버에서 실행하더라도 기존 Docker 컨테이너와 K3s의 Pod는 관리 주체가 다르다. 기존 서비스는 Docker에 남겨 두고, K3s는 자체 containerd로 새 워크로드를 실행하도록 구성했다. K3s의 런타임 옵션은 [공식 Agent 설정 문서](https://docs.k3s.io/cli/agent?ref=mlog.me#runtime)에서 확인할 수 있다.

따라서 `docker ps`에는 기존 컨테이너가 보이고, K3s에 배포한 앱은 `sudo k3s kubectl get pods -A`로 확인한다. 쿠버네티스를 설치했다고 Docker 컨테이너가 자동으로 Pod로 바뀌는 것은 아니다.

## 80·443 포트는 기존 NPM이 계속 담당하도록 했다

내 서버에서는 Nginx Proxy Manager, 이하 NPM이 이미 80·443 포트를 사용하고 있었다. 이 구조를 유지하려면 K3s의 기본 구성 요소부터 조정해야 했다.

K3s에 기본으로 포함되는 Traefik은 인그레스 역할을 맡고, 기본 ServiceLB와 함께 사용할 때 80·443 포트를 사용한다. 이번에는 외부 요청을 기존 NPM이 계속 받도록 **Traefik과 ServiceLB를 모두 비활성화했다.** 관련 동작은 [K3s 네트워킹 서비스 문서](https://docs.k3s.io/networking/networking-services?ref=mlog.me)에 설명되어 있다.

역할은 다음처럼 나눴다.

| 구간                  | 담당                               |
| ------------------- | -------------------------------- |
| 브라우저의 HTTPS 접속과 인증서 | Docker에서 실행 중인 NPM               |
| 테스트 앱으로 전달할 주소      | A1의 Tailscale IP와 NodePort 30081 |
| 준비된 Pod로 트래픽 전달     | Kubernetes Service와 kube-proxy   |
| 실제 HTTP 응답          | K3s의 hello-web Pod 두 개           |

브라우저가 테스트 도메인에 접속하면 NPM이 HTTPS 요청을 받고, 내부적으로 `http://100.101.102.103:30081`에 전달한다. 이후 쿠버네티스의 서비스 네트워크를 거쳐 준비된 Pod가 응답한다.

이렇게 구성하면 기존 블로그의 프록시 설정을 유지하면서 쿠버네티스 앱을 별도로 연결할 수 있다. NPM이 Pod IP를 직접 기억할 필요도 없다.

## 설치 전에 자원과 네트워크부터 확인했다

설치 전에는 메모리와 디스크 여유, 기존 포트 사용 상태, Tailscale 동작 여부를 확인했다. 당시 사용 가능한 메모리는 약 22GiB, 남은 디스크 공간은 약 70GB였다.

확인에 사용하는 명령은 다음과 같다.

```bash
uname -m
free -h
df -h /
stat -fc %T /sys/fs/cgroup
sudo ss -lntup
ip -4 addr show dev tailscale0
sudo docker ps

```

특히 신경 쓴 부분은 네트워크 대역이었다. 이번 구성에서는 Pod에 `10.42.0.0/16`, Service에 `10.43.0.0/16`을 사용했다. 기존 Docker 네트워크나 서버의 라우팅 대역과 겹치면 통신 문제가 생길 수 있으므로, 설치 스크립트에서 충돌 여부를 검사하도록 했다.

기존 서비스의 상태도 설치 전에 기록했다. 실행 중인 Docker 컨테이너의 ID, 재시작 횟수, 헬스체크 상태를 저장하고, 설치 후 같은 컨테이너가 정상적으로 동작하는지 비교하는 방식이다. 기존 블로그의 HTTPS 응답도 함께 확인했다.

## K3s 주소와 NodePort를 Tailscale에 맞췄다

K3s 설정은 `/etc/rancher/k3s/config.yaml`에 작성했다. 아래는 실제 구성에서 주소를 예시 값으로 바꾼 내용이다.

```yaml
node-name: goldensupp-oci
node-ip: 100.101.102.103
advertise-address: 100.101.102.103
tls-san:
  - 100.101.102.103

flannel-iface: tailscale0
flannel-backend: vxlan
cluster-cidr: 10.42.0.0/16
service-cidr: 10.43.0.0/16
cluster-dns: 10.43.0.10

disable:
  - traefik
  - servicelb

write-kubeconfig-mode: "0600"
secrets-encryption: true

kube-proxy-arg:
  - proxy-mode=iptables
  - nodeport-addresses=100.101.102.103/32

```

노드 주소와 API가 알리는 주소를 Tailscale IP로 맞추고, 해당 주소로 API에 접속할 수 있도록 인증서의 SAN에도 추가했다. Flannel은 `tailscale0`을 사용하도록 지정했다.

다만 `node-ip`나 `advertise-address`를 지정하는 것만으로 API의 외부 접근이 차단되는 것은 아니다. 이번에는 별도의 방화벽 규칙을 함께 적용했다.

NodePort에서는 `nodeport-addresses`가 핵심이다. 이 설정은 NodePort를 제공할 노드 주소 범위를 지정한다. 이번에는 Tailscale IP 하나만 선택했으므로 NPM의 전달 대상도 같은 주소를 사용했다. `127.0.0.1:30081`이나 공인 IP를 입력해도 동일하게 동작할 것이라고 가정하면 안 된다. 자세한 동작은 [Kubernetes Service 문서](https://kubernetes.io/docs/concepts/services-networking/service/?ref=mlog.me#type-nodeport)를 참고할 수 있다.

또한 이 옵션은 접근할 주소를 선택하는 설정이다. 접속할 수 있는 사용자를 제한하는 일은 Tailscale 접근 정책과 방화벽이 맡는다.

### 호스트의 swap은 유지하고, Pod에는 NoSwap을 적용했다

서버에는 기존부터 8GiB의 swap이 있었다. 이를 유지하면서 Kubernetes Pod가 swap을 사용하지 않도록 kubelet 설정을 별도로 작성했다.

경로는 `/var/lib/rancher/k3s/agent/etc/kubelet.conf.d/10-a1.conf`다.

```yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration

failSwapOn: false
memorySwap:
  swapBehavior: NoSwap

systemReserved:
  cpu: 500m
  memory: 4Gi
kubeReserved:
  cpu: 250m
  memory: 1Gi

evictionHard:
  memory.available: 1Gi
  nodefs.available: "10%"
  nodefs.inodesFree: "5%"
  imagefs.available: "15%"
  imagefs.inodesFree: "5%"

```

`failSwapOn: false`는 호스트에 swap이 있다는 이유로 kubelet이 시작에 실패하지 않도록 하는 설정이다. `NoSwap`은 Kubernetes 워크로드가 swap을 사용하지 않게 한다. 호스트에서 동작하는 다른 프로세스의 swap 사용과는 구분된다. 이 차이는 [Kubernetes의 Linux swap 동작 문서](https://kubernetes.io/docs/reference/node/swap-behavior/?ref=mlog.me)에서 확인할 수 있다.

기존 Docker 서비스와 운영체제가 사용할 여유도 고려해 자원을 예약했다. `systemReserved`와 `kubeReserved`는 Pod에 배정할 수 있는 자원을 계산하는 데 반영된다. 이 값 자체가 Docker 컨테이너의 CPU·메모리 상한을 설정하는 것은 아니다.

이 설정 파일 방식은 이번에 사용한 K3s 버전에서 지원된다. K3s의 kubelet 설정 디렉터리와 지원 버전은 [설정 문서](https://docs.k3s.io/installation/configuration?ref=mlog.me#kubelet-configuration-files)에 정리되어 있다.

## 설치할 때 서비스 시작 순서도 함께 구성했다

실제 설치에는 내 A1 서버용으로 준비한 `install-k3s-a1-20261027.sh`를 사용했다. 이 스크립트는 호스트 이름과 아키텍처를 확인하고, 이미 K3s 설치 흔적이 있으면 덮어쓰지 않도록 구성했다.

설치 버전은 공식 `stable` 채널에서 한 번 조회한 뒤, 선택된 값을 `INSTALL_K3S_VERSION`으로 전달했다. 이번 설치 후 확인된 값이 `v1.36.5+k3s1`이다. 다른 날짜에 같은 채널을 조회하면 선택되는 버전은 달라질 수 있다.

공식 설치 스크립트를 호출할 때는 다음 옵션을 사용했다. 각 옵션의 의미는 [K3s 설치 환경 변수 문서](https://docs.k3s.io/reference/env-variables?ref=mlog.me)를 기준으로 정리했다.

| 옵션                              | 이번 설치에서의 역할              |
| ------------------------------- | ------------------------ |
| INSTALL\_K3S\_VERSION           | 선택한 버전을 명시적으로 전달         |
| INSTALL\_K3S\_EXEC=server       | 서버 역할로 설치                |
| INSTALL\_K3S\_SYMLINK=skip      | 명령어 심볼릭 링크 생성 생략         |
| INSTALL\_K3S\_SKIP\_ENABLE=true | 설치 과정에서 서비스 자동 활성화·시작 생략 |
| INSTALL\_K3S\_SKIP\_START=true  | 설치 직후 서비스 자동 시작 생략       |

설정과 보호 규칙을 먼저 준비하고, 그다음 K3s 서비스를 활성화하고 시작하는 순서였다.

Tailscale도 시작 순서에 포함했다. systemd에서 `tailscaled.service` 뒤에 실행되도록 지정하는 것에 더해, 실제 `tailscale0`에 기대한 IP가 할당됐는지 확인하는 시작 전 검사도 넣었다. 데몬이 시작됐다는 사실과 필요한 주소가 준비됐다는 사실은 같지 않기 때문이다.

방화벽에는 IPv4·IPv6용 `GS-K3S-IN` 전용 체인을 만들었다. API 서버의 TCP `6443`, kubelet의 TCP `10250`, Flannel VXLAN의 UDP `8472`에 대해 Tailscale과 필요한 내부 통신 경로를 고려하고, 나머지 입력을 제한했다. 기존 Docker 규칙 전체를 초기화하거나 `FORWARD` 기본 정책을 일괄 변경하는 방식은 사용하지 않았다.

특히 UDP `8472`는 일반 웹 서비스용 포트가 아니다. [K3s 공식 문서](https://docs.k3s.io/installation/requirements?ref=mlog.me#networking)도 VXLAN 포트를 인터넷에 공개하지 않도록 안내한다.

## 설치 완료는 기존 서비스와 Pod 통신까지 확인했다

K3s가 시작된 뒤에는 API 준비 상태와 노드, 기본 구성 요소를 확인했다.

```bash
sudo systemctl is-active k3s
sudo k3s kubectl get --raw=/readyz
sudo k3s kubectl get nodes -o wide
sudo k3s kubectl get pods -A -o wide
sudo k3s kubectl get storageclass

```

노드 `goldensupp-oci`는 `Ready`였고, CoreDNS·local-path-provisioner·metrics-server도 정상적으로 올라왔다. 기본 StorageClass는 `local-path`였다.

이어 임시 Pod에서 클러스터 내부 DNS, 외부 DNS, 외부 HTTP 통신을 확인했다. 기존 Docker 컨테이너 상태와 블로그 응답도 설치 전 기록과 대조했다. 검증 출력에서 확인한 주요 결과는 다음과 같다.

```text
POD_NETWORK_OK
DOCKER_CONTAINERS_OK
MLOG_HTTPS_OK status=200
K3S_A1_VERIFIED

```

`MLOG_HTTPS_OK` 검사는 서버 내부에서 도메인을 루프백 주소로 연결해 NPM과 기존 블로그의 HTTPS 응답을 확인한 것이다. 외부 인터넷의 모든 접속 경로를 검사했다는 뜻은 아니다.

이번 설치에서는 클러스터 생성뿐 아니라, Pod 통신과 기존 Docker 서비스가 함께 정상 동작하는 것까지 확인했다.

## 웹 앱을 배포하고 NPM으로 HTTPS 연결하기

이제 클러스터에 작은 웹 앱을 올렸다. 단순한 환영 화면 대신, 요청을 처리한 Pod의 정보를 보여 주도록 만들었다. 그래야 접속 성공과 요청 분산을 함께 확인할 수 있다.

배포 구성은 다음과 같다.

| 항목         | 값                  |
| ---------- | ------------------ |
| Namespace  | k3s-demo           |
| Deployment | hello-web          |
| Pod 개수     | 2개                 |
| 컨테이너 이미지   | python:3.13-alpine |
| 앱 포트       | 8080               |
| Service 포트 | 80                 |
| NodePort   | 30081              |
| 상태 확인      | /healthz           |
| 응답 정보      | /api/info          |

Python 코드와 화면은 ConfigMap으로 전달하고, Deployment가 동일한 앱을 두 개 실행하도록 했다. 앱에는 Downward API로 Pod 이름·Pod IP·노드 이름을 전달했다.

Service 부분은 아래와 같다. 이 YAML은 전체 테스트 앱 매니페스트 중 Service만 발췌한 것이다.

```yaml
apiVersion: v1
kind: Service
metadata:
  name: hello-web
  namespace: k3s-demo
spec:
  type: NodePort
  externalTrafficPolicy: Cluster
  selector:
    app: hello-web
  ports:
    - name: http
      port: 80
      targetPort: http
      nodePort: 30081

```

여기서 `targetPort: http`는 컨테이너에 `http`라는 이름으로 선언한 `8080` 포트를 가리킨다. NPM은 NodePort `30081`로 접속하지만, 앱 자체는 컨테이너 안의 `8080`에서 응답한다.

배포 후 상태 확인에는 다음 명령을 사용하면 된다.

```bash
sudo k3s kubectl -n k3s-demo rollout status deployment/hello-web --timeout=180s
sudo k3s kubectl -n k3s-demo get pods -o wide
sudo k3s kubectl -n k3s-demo get svc hello-web

# 예시 IP를 자신의 Tailscale IP로 바꿔 실행한다.
curl --noproxy '*' http://100.101.102.103:30081/healthz
curl --noproxy '*' http://100.101.102.103:30081/api/info

```

실제 검증에서는 Pod 두 개가 `Running` 상태였고, 내부 HTTP 요청도 `200 OK`로 응답했다.

### readiness 오류는 현재 상태와 함께 봐야 했다

배포 직후 이벤트에는 readiness probe의 `connection refused`가 남았다. 이후 앱이 정상화됐고 `/healthz` 응답과 두 Pod의 준비 상태도 확인됐다.

설정상 readiness 검사는 컨테이너 시작 1초 뒤부터 실행되도록 되어 있었다. 초기의 연결 거부는 앱이 포트를 열기 전에 검사가 실행된 상황과 부합한다. 다만 과거 이벤트 한 줄만 보고 원인을 단정하기보다, 현재 Ready 상태와 반복 발생 여부를 함께 확인하는 편이 정확하다.

readiness 실패는 그 Pod가 아직 요청을 받을 준비가 되지 않았다는 의미다. 컨테이너 재시작을 판단하는 liveness 검사와 역할이 다르다. 이 구분은 [Kubernetes Probe 문서](https://kubernetes.io/docs/concepts/workloads/pods/probes/?ref=mlog.me)에 설명되어 있다.

### 내부 HTTP가 확인된 다음 NPM을 연결했다

NPM의 전달 대상은 다음과 같이 구성한다. 도메인과 IP는 예시다.

| NPM 항목                | 값                  |
| --------------------- | ------------------ |
| Domain Names          | k3s.example.com    |
| Scheme                | http               |
| Forward Hostname / IP | 100.101.102.103    |
| Forward Port          | 30081              |
| Cache Assets          | 비활성화               |
| SSL                   | 테스트 도메인의 인증서 발급·적용 |

이 단계에서는 인증서가 아직 발급되지 않은 문제가 있었다. 내부 NodePort 접속은 정상이었고, 인증서를 발급한 뒤 테스트 도메인의 HTTPS 접속도 성공했다.

이 경험에서 유용했던 것은 연결을 구간별로 나눠 본 점이다. 먼저 앱과 NodePort의 HTTP 응답을 확인하고, 그다음 NPM의 전달 대상과 도메인 인증서를 확인하니 문제 범위를 좁히기 쉬웠다.

### 두 Pod가 실제로 응답하는지 확인했다

테스트 화면에는 최근 응답한 Pod 이름, 노드, Pod IP, CPU 아키텍처가 표시되도록 했다. 한 번 요청하는 버튼과 열 번 연속 요청하는 버튼을 두고, Pod별 응답 횟수도 기록했다.

검증에서는 열 번의 요청을 통해 서로 다른 두 Pod가 응답하는 것을 확인했다. 테스트 앱은 응답 캐시를 사용하지 않고 연결을 닫도록 구성해, 반복 요청에서 서로 다른 Pod의 응답을 관찰하기 쉽게 했다.

여기서 확인하려던 것은 정확한 5 대 5 분배가 아니다. 두 Pod가 서비스의 대상으로 동작하며 실제 요청을 처리하는지였다. 연결 재사용과 트래픽 분산 방식에 따라 요청이 매번 번갈아 전달되지는 않는다.

또한 Pod 두 개는 모두 같은 A1 노드에서 실행된다. 요청이 두 Pod로 분산된다는 사실과 서버 장애에도 서비스가 유지된다는 것은 별개의 문제다. 이번 구성은 단일 노드이므로 A1 자체가 중단되면 두 Pod도 함께 영향을 받는다.

## Portainer 연결에서는 서비스 유형과 인증 설정을 확인했다

웹 앱 검증 뒤에는 기존 Portainer에서 K3s를 관리할 수 있도록 Agent를 연결했다. 이 과정에서도 앞서 선택한 K3s 설정이 영향을 줬다.

처음에는 Agent Service가 `LoadBalancer` 유형이었고, 외부 IP가 `<pending>` 상태로 남았다. 이번 클러스터에서는 ServiceLB를 비활성화했고 별도의 LoadBalancer 구현도 연결하지 않았기 때문에, 외부 IP가 자동으로 할당되지 않는 구성이었다.

여기서 Agent Pod가 실행 중인지와 Service의 외부 IP가 준비됐는지는 나눠서 봐야 한다. `EXTERNAL-IP`의 `<pending>` 표시만으로 Pod 실행까지 실패했다고 판단할 수는 없다.

이후 Agent를 NodePort 방식으로 다시 설치했다. 확인된 포트는 `30778`이었고, Agent Pod는 `1/1 Running` 상태였다. Portainer에서 접속할 대상은 예시 주소로 표현하면 `100.101.102.103:30778`이다.

네트워크 연결과 함께 `AGENT_SECRET`도 맞췄다. Portainer Server에 해당 값을 설정해 사용하고 있다면 Agent에도 같은 값을 전달해야 한다. 이 설정을 일치시킨 뒤 Portainer에서 K3s 환경에 접속할 수 있었다. 연결 방식과 인증 조건은 [Portainer의 Kubernetes Agent 설치 문서](https://docs.portainer.io/admin/environments/add/kubernetes/agent?ref=mlog.me)에 설명되어 있다.

## 이번 구축에서 확인한 범위

이번에는 A1 한 대에 K3s를 추가하고, 기존 Docker 서비스가 유지되는 상태에서 웹 앱 배포와 HTTPS 연결을 확인했다. 설치 결과는 다음과 같다.

| 확인 항목         | 결과                                               |
| ------------- | ------------------------------------------------ |
| K3s 노드        | goldensupp-oci가 Ready                            |
| 기본 구성 요소      | CoreDNS·local-path-provisioner·metrics-server 정상 |
| Pod 네트워크      | 내부 DNS·외부 DNS·외부 HTTP 확인                         |
| 기존 Docker 서비스 | 설치 전후 상태 비교 통과                                   |
| 기존 블로그        | 서버 내부 HTTPS 검사 200                               |
| 테스트 앱         | Pod 2개 실행 및 HTTP 응답 확인                           |
| 외부 접속         | NPM 인증서 발급 후 테스트 도메인 HTTPS 성공                    |
| 요청 분산         | 두 Pod의 응답 확인                                     |
| 관리 도구         | Portainer 연결 성공                                  |

직접 구성해 보니, 설치 이후에 확인할 연결이 더 선명해졌다. Pod가 실행되는 것, Service를 통해 접근하는 것, NPM에서 HTTPS로 공개하는 것은 각각 확인할 지점이 있었다. 작은 테스트 앱 하나로 이 흐름을 끝까지 따라가 볼 수 있었다.

기존 서비스를 운영하면서도 쿠버네티스를 실습할 기반이 마련됐다. 이번에 남긴 설정과 확인 절차는 다음 앱을 배포할 때도 사용할 수 있다. 다만 상태를 저장하는 서비스를 올릴 때는 이번 웹 앱 테스트에 포함되지 않은 데이터 보존과 복구 과정을 추가로 확인해야 한다.