Commit a59e2e71 authored by insun park's avatar insun park
Browse files

feat: Add Docker KVM Windows VM project

parent fc7634e2
# 1. Base Image
FROM ubuntu:20.04
# 2. Prevent interactive prompts
ENV DEBIAN_FRONTEND=noninteractive
# 3. Install dependencies
RUN apt-get update && \
apt-get install -y qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virt-manager wget unzip gnupg kmod openssh-client net-tools
# 4. Install HashiCorp GPG key and repository
RUN wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=VERSION_CODENAME=).*' /etc/os-release) main" | tee /etc/apt/sources.list.d/hashicorp.list
# 5. Install build dependencies for vagrant plugins
RUN apt-get update && \
apt-get install -y build-essential libvirt-dev ruby-dev
# 6. Install Vagrant from .deb package
RUN wget https://releases.hashicorp.com/vagrant/2.4.6/vagrant_2.4.6-1_amd64.deb && \
apt-get install -y ./vagrant_2.4.6-1_amd64.deb
# 7. Install vagrant-libvirt plugin
RUN vagrant plugin install vagrant-libvirt
# Add root to libvirt group and configure qemu.conf
RUN usermod -aG libvirt $(whoami) && \
sed -i 's/#user = "root"/user = "root"/' /etc/libvirt/qemu.conf && \
sed -i 's/#group = "root"/group = "root"/' /etc/libvirt/qemu.conf
# 8. Download Vagrant Box
RUN vagrant box add peru/windows-10-enterprise-x64-eval --provider libvirt
# 9. Create and set working directory
RUN mkdir -p /opt/win10
WORKDIR /opt/win10
# 10. Copy Vagrantfile
COPY Vagrantfile /opt/win10/
# 11. Create and provision the VM during the build
# This step will download the Windows image and provision it.
# It can take a significant amount of time.
# RUN /usr/sbin/libvirtd -d && /usr/sbin/virtlogd -d && VAGRANT_DEFAULT_PROVIDER=libvirt vagrant up
# 12. Copy startup script
COPY startup.sh /usr/local/bin/startup.sh
RUN chmod +x /usr/local/bin/startup.sh
EXPOSE 3389
# 13. Set the entrypoint
CMD ["/usr/local/bin/startup.sh"]
\ No newline at end of file
### Docker-KVM 기반 Windows VM 동작 원리 및 설정 과정 분석
#### 1. 개요
이 시스템은 Docker 컨테이너 내부에 KVM(Kernel-based Virtual Machine) 하이퍼바이저를 설치하고, 그 위에 Vagrant를 사용하여 Windows 10 가상 머신을 구동하는 복합적인 가상화 구조입니다. 사용자는 최종적으로 RDP(원격 데스크톱 프로토콜)를 통해 Docker 외부에서 이 Windows VM에 접속할 수 있습니다.
이 문서의 목표는 `docker-compose up` 명령부터 최종 RDP 접속까지의 전 과정을 단계별로 설명하고, 우리가 겪었던 네트워크 문제의 원인과 해결책을 명확히 이해하는 것입니다.
#### 2. 전체 아키텍처
```mermaid
graph TD;
subgraph 사용자 PC
A[RDP 클라이언트]
end
subgraph 외부 네트워크
B(Host IP:33890)
end
subgraph "Docker 호스트 (Server)"
C{Host iptables<br/>FORWARD Chain}
D[Docker 데몬]
subgraph "Docker 컨테이너 (Ubuntu)"
E[libvirtd 데몬]
F[Vagrant]
G[Container iptables]
subgraph "KVM 가상 머신"
H[Windows 10 VM<br/>Internal IP: 192.168.121.x<br/>RDP Port: 3389]
end
end
end
A --"1. RDP 접속 시도"--> B;
B --"2. 호스트 도착"--> C;
C --"3. 방화벽 통과"--> D;
D --"4. 포트 포워딩<br/>(33890 -> 3389)"--> G;
G --"5. 내부 포워딩<br/>(Container:3389 -> VM:3389)"--> H;
```
#### 3. 단계별 동작 과정
1. **`docker-compose up` - 컨테이너 생성**
* `Dockerfile`에 정의된 대로 Ubuntu 20.04 이미지를 기반으로 새로운 컨테이너가 생성됩니다.
* 컨테이너 내부에 `apt-get`을 통해 `qemu-kvm`, `libvirt`, `vagrant` 등 VM을 구동하는 데 필요한 모든 소프트웨어가 설치됩니다. 이 시점에서 컨테이너는 그 자체로 하나의 작은 "가상화 서버"가 됩니다.
* `docker-compose.yml``ports: - "33890:3389"` 설정에 따라 Docker는 호스트의 `33890`번 포트로 들어오는 모든 요청을 컨테이너의 `3389`번 포트로 전달하도록 **호스트의 `iptables`에 `DNAT` 규칙을 자동으로 추가**합니다.
2. **`startup.sh` - VM 부팅 및 내부 설정**
* 컨테이너가 시작되면 `startup.sh` 스크립트가 실행됩니다.
* **libvirt 데몬 실행:** KVM 가상 머신을 관리하는 `libvirtd` 서비스를 활성화합니다.
* **`vagrant up` 실행:** Vagrant가 `Vagrantfile`의 내용을 읽어 Windows 10 VM을 생성하고 부팅합니다.
* `vagrant-libvirt` 플러그인을 사용하여 KVM 위에서 VM을 동작시킵니다.
* 부팅이 완료되면 VM은 `libvirt`가 만든 내부 가상 네트워크(예: `192.168.121.0/24`)로부터 IP 주소(예: `192.168.121.251`)를 할당받습니다.
* **컨테이너 내부 RDP 포워딩 설정:** 스크립트는 VM의 IP를 알아낸 뒤, **컨테이너의 `iptables`**를 사용하여 컨테이너의 3389 포트로 들어온 요청을 Windows VM의 IP와 3389 포트로 전달하는 `DNAT` 규칙을 추가합니다.
#### 4. 우리가 마주했던 문제와 해결 과정
모든 설정이 자동화된 것 같았지만, 우리는 RDP 접속에 실패했습니다. `nmap` 스캔 결과 포트가 `closed` 또는 `filtered` 상태로 나타났습니다.
**핵심 원인: 호스트 방화벽의 `FORWARD` 체인 정책**
- `iptables`에는 여러 '체인(chain)'이 있으며, 패킷은 정해진 순서대로 체인을 통과하며 검사를 받습니다.
- **`INPUT` 체인:** 호스트 자신에게 들어오는 패킷을 처리합니다.
- **`FORWARD` 체인:** 호스트를 거쳐 다른 곳(우리의 경우, Docker 컨테이너)으로 **전달되는** 패킷을 처리합니다.
- 보안이 강화된 서버는 외부에서 들어온 패킷이 내부망으로 전달되는 것을 막기 위해 **`FORWARD` 체인의 기본 정책을 `DROP`(모두 차단)으로 설정**하는 경우가 많습니다. 저희 서버가 바로 이 경우였습니다.
Docker는 `FORWARD` 정책이 `DROP`일 것을 대비하여 자동으로 예외 규칙을 추가해주지 않습니다. 따라서 `33890` 포트로 들어온 패킷이 `DNAT`되어 컨테이너로 전달되려 할 때, `FORWARD` 체인의 `DROP` 정책에 의해 그냥 버려지고 있었던 것입니다.
**잘못된 시도와 그 이유**
1. **`startup.sh`에서 호스트 방화벽 수정 시도:** `startup.sh`는 컨테이너 안에서 실행되므로, 보안상 격리된 컨테이너가 호스트의 방화벽 규칙을 수정할 수 없어 실패했습니다.
2. **`--dport 33890` 규칙 추가:** `FORWARD` 체인에서 `--dport 33890` 규칙으로 패킷을 잡으려 했지만, 패킷이 `FORWARD` 체인에 도달하기 전에 `PREROUTING` 체인에서 이미 목적지 포트가 컨테이너의 포트인 `3389``DNAT`된 후였습니다. 따라서 조건이 맞지 않아 규칙이 동작하지 않았습니다.
**최종 해결책**
1. **올바른 규칙 추가:** `FORWARD` 체인에서는 이미 `DNAT`가 끝난 상태이므로, 변환된 후의 **최종 목적지(컨테이너 IP와 포트)**를 기준으로 규칙을 만들어야 했습니다.
```bash
iptables -I DOCKER-USER -d 172.29.0.2 -p tcp --dport 3389 -j ACCEPT
```
이 규칙은 "최종 목적지가 `172.29.0.2``3389` 포트인 TCP 패킷은 `DOCKER-USER` 체인을 통과시켜라" 라는 의미입니다. `DOCKER-USER` 체인은 `FORWARD` 체인의 맨 앞에서 호출되므로, 기본 `DROP` 정책보다 먼저 이 규칙이 적용되어 패킷이 통과될 수 있었습니다.
2. **규칙 영구 저장:** 이 규칙은 호스트에 설정되어야 하는 "사전 환경 조건"이므로, `docker-compose`와는 별개로 호스트에 직접 추가해야 했습니다. `iptables-persistent` 패키지를 설치하고 `netfilter-persistent save` 명령어로 규칙을 영구 저장하여, 서버가 재부팅되어도 설정이 유지되도록 만들었습니다.
#### 5. 결론
이 시스템은 **두 단계의 네트워크 주소 변환(NAT)****두 개의 방화벽(호스트, 컨테이너)**이 중첩된 복잡한 구조입니다.
- **1차 NAT (호스트):** 외부 IP:33890 -> 컨테이너 IP:3389
- **2차 NAT (컨테이너):** 컨테이너 IP:3389 -> VM IP:3389
문제 해결의 핵심은 패킷의 흐름을 정확히 이해하고, 각 단계에서 어떤 방화벽(체인)이 패킷을 검사하는지, 그리고 그 시점에 패킷의 목적지 주소가 무엇인지를 파악하는 것이었습니다. 최종적으로 호스트의 `FORWARD` 정책이라는 근본 원인을 찾아내고, 그에 맞는 정확한 `iptables` 규칙을 **올바른 위치(호스트)에** 추가함으로써 문제를 해결할 수 있었습니다.
\ No newline at end of file
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
config.vm.box = "peru/windows-10-enterprise-x64-eval"
config.vm.provider "libvirt" do |libvirt|
libvirt.memory = 8192
libvirt.cpus = 4
end
config.vm.provision "shell", inline: <<-SHELL
# Install Chocolatey if not already installed
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
}
# Install Java 17
choco install openjdk17 -y
# Installing PowerPoint via Chocolatey is not straightforward
# as it requires the Office Deployment Tool (ODT) and a configuration.xml file.
# This section can be expanded to use ODT for an automated installation.
# For now, this step is manual.
Write-Host "Please install Microsoft PowerPoint manually or configure the Office Deployment Tool."
SHELL
end
\ No newline at end of file
services:
win10-kvm:
build: .
image: win10-kvm:latest
container_name: win10-container
privileged: true
security_opt:
- "apparmor:unconfined"
cap_add:
- NET_ADMIN
devices:
- /dev/kvm
- /dev/net/tun
ports:
- "33890:3389"
volumes:
- /lib/modules:/lib/modules:ro
\ No newline at end of file
#!/bin/bash
set -e
# Start libvirt and virtlogd services
echo "Starting libvirt and virtlogd daemons..."
/usr/sbin/libvirtd -d
/usr/sbin/virtlogd -d
sleep 2 # Give daemons a moment to start
# Change to the Vagrant project directory
cd /opt/win10 || exit
# Bring up the VM. This command is idempotent.
echo "Bringing up the VM..."
vagrant up
# Get the VM IP address using virsh
echo "Fetching VM IP address..."
DOMAIN_NAME="win10_default"
# It can take a while for the guest agent to report the IP address.
# We will retry a few times.
VM_IP=""
for i in {1..12}; do # Retry for 2 minutes (12 * 10s)
# The output of domifaddr can be multiline, we are interested in ipv4
# The output looks like:
# Name MAC address Protocol Address
# -------------------------------------------------------------------------------
# vnet1 52:54:00:ab:cd:ef ipv4 192.168.121.44/24
VM_IP=$(virsh domifaddr "$DOMAIN_NAME" 2>/dev/null | grep ipv4 | awk '{print $4}' | cut -d'/' -f1)
if [ -n "$VM_IP" ]; then
echo "VM IP Address found: $VM_IP"
break
fi
echo "Waiting for VM to get an IP address... (attempt $i/12)"
sleep 10
done
if [ -z "$VM_IP" ]; then
echo "Fatal: Failed to get VM IP address after multiple retries."
exit 1
fi
echo "Windows VM IP Address: $VM_IP"
# On the Docker HOST, add a rule to the DOCKER-USER chain to allow
# incoming RDP traffic to be forwarded to the container.
# This is the correct way to allow traffic when the FORWARD policy is DROP.
echo "Allowing RDP forwarding on the Docker host..."
iptables -I DOCKER-USER -p tcp --dport 33890 -j ACCEPT
# Inside the container, configure iptables for RDP port forwarding to the VM
echo "Configuring iptables for RDP..."
iptables -t nat -A PREROUTING -p tcp --dport 3389 -j DNAT --to-destination "$VM_IP":3389
iptables -t nat -A POSTROUTING -j MASQUERADE
echo "Port forwarding rule added."
echo "RDP connections to this container on port 3389 will be forwarded to the Windows VM."
# Keep the container running
echo "Container is running. Use 'docker exec' to access it."
tail -f /dev/null
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment