SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-09-01

systemd 서비스 보안 설정: ProtectSystem 활용법

ProtectSystem, PrivateTmp, DynamicUser 등 systemd 샌드박싱 지시어의 동작 원리를 설명합니다. 각 설정이 차단하는 기능과 서비스 실행 실패 시 발생하는 오류를 디버깅하는 구체적인 방법을 안내합니다.

systemd 샌드박싱의 역할

systemd 샌드박싱은 유닛 파일을 작성하는 과정의 절반을 차지합니다. Type=는 서비스가 어떻게 시작될지를 결정하며, ProtectSystem=PrivateTmp=과 같은 지시어는 프로세스가 실행된 후 무엇에 접근할 수 있는지를 결정합니다. 이는 커널 기능, 마운트 네임스페이스, seccomp 필터를 활용하는 것으로, 프로세스가 제어권을 갖기 전에 systemd가 적용합니다. 애플리케이션은 이러한 설정을 직접 인지하지 못하며, 별도의 코드 수정도 필요하지 않습니다.

기본값은 아무런 제한도 없는 상태입니다. 샌드박싱이 없는 유닛은 root 권한으로 실행되며, 어디에나 파일을 쓸 수 있고, 시스템의 모든 파일을 읽을 수 있으며, 커널 모듈을 로드할 수도 있습니다. 만약 해당 유닛이 인터넷에서 접근 가능한 웹 애플리케이션이라면, 파일 업로드 버그 하나가 서버 전체의 침해로 이어질 수 있습니다. 아래의 유닛 설정을 적용하면, 동일한 애플리케이션이라도 읽기 전용 파일 시스템을 사용하게 되며, /home는 비어 있게 되고, 다른 프로세스가 볼 수 없는 /tmp을 가지게 됩니다. 또한 setuid 바이너리를 찾아내더라도 root 권한을 획득할 경로가 차단됩니다.

이 모든 설정은 유닛 단위로 적용됩니다. 특정 서비스를 강화한다고 해서 주변 서비스가 보호되는 것은 아니므로, 공개 포트에서 대기 중인 서비스부터 우선적으로 적용하십시오.

유닛 파일 한 번에 작성하기

notes는 소규모 웹 서비스입니다. 이 서비스는 localhost에서 수신 대기하며, /var/lib/notes 경로에 SQLite 데이터베이스를 유지하고, nginx 뒤에서 동작합니다. Type=exec은 바이너리가 포그라운드에서 실행되므로 적합하며, Type=simple, exec, forking, notify의 차이에 따라 systemd가 시작 과정을 추적하는 방식이 결정됩니다. 아래의 모든 지시어는 뒤에서 자세히 설명하며, 각 지시어가 방지하는 문제와 흔히 발생하는 오류를 다룹니다.

[Unit]
Description=Notes web service
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
ExecStart=/opt/notes/bin/notes --listen 127.0.0.1:8080
DynamicUser=yes
StateDirectory=notes
Environment=NOTES_DB=/var/lib/notes/notes.db

ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectProc=invisible

NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes

ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes

[Install]
WantedBy=multi-user.target

이 내용을 /etc/systemd/system/notes.service에 작성하고, sudo systemctl daemon-reload을 실행한 뒤 sudo systemctl restart notes.service를 실행합니다. 패키지에서 제공된 유닛 파일인 경우, 공급업체 파일을 직접 수정하지 마십시오. sudo systemctl edit notes.service을 실행하면 /etc/systemd/system/notes.service.d/override.conf에 드롭인 파일이 열리며, 여기에 [Service] 추가 설정을 작성하면 패키지 업그레이드 시에도 설정이 유지됩니다. systemctl cat notes.service은 공급업체 파일부터 시작하여 병합된 최종 결과를 출력합니다.

서비스 실행 계정

User=를 사용하지 않으면 서비스는 root 권한으로 실행되며, 이 경우 다른 모든 지시어는 피해를 최소화하기 위한 수단에 불과합니다. 이를 해결하는 방법은 두 가지가 있습니다.

정적 시스템 사용자. sudo useradd --system --no-create-home --shell /usr/sbin/nologin notes로 사용자를 생성한 뒤, 유닛 파일에 User=notes을 설정합니다. UID가 재시작이나 재부팅 후에도 동일하게 유지되므로, 서비스가 자체 상태 디렉터리 외부의 파일을 소유하거나 백업 작업이 해당 파일을 읽어야 할 때 유용합니다. 모든 서비스에 별도의 비권한 계정을 부여하는 원칙이 여기에도 그대로 적용됩니다.

DynamicUser=yes. 서비스가 시작될 때 systemd가 예약된 범위 내에서 UID를 할당하고, 서비스가 중지되면 이를 해제합니다. 디스크에 계정이 생성되지 않으므로 서비스를 삭제해도 남는 것이 없습니다. 서비스가 실행되는 동안 getent passwd notes은 systemd의 NSS(name service switch) 모듈을 통해 이름을 해석합니다. 서비스가 중지되면 이름도 사라집니다.

DynamicUser=yes를 사용하면 RemoveIPC=yes, PrivateTmp=yes, ProtectSystem=strict, ProtectHome=read-only 등 네 가지 설정이 자동으로 활성화됩니다. 단 한 줄의 설정으로 샌드박스 환경의 대부분을 구성할 수 있기 때문에 예제 유닛 파일이 간결하게 유지됩니다.

주의 사항. 동적 UID는 임의의 위치에 있는 파일을 소유할 수 없습니다. 서비스 간에 번호가 재사용되기 때문입니다. 영구 데이터는 반드시 StateDirectory=, CacheDirectory= 또는 LogsDirectory=을 거쳐야 하며, systemd는 시작할 때마다 해당 디렉터리를 생성하고 현재 UID로 소유권을 변경합니다. DynamicUser=yes을 사용하면 실제 디렉터리는 /var/lib/private/notes이 되고, /var/lib/notes는 이를 가리키는 심볼릭 링크가 됩니다. /var/lib/private은 모드 0700으로 설정되며 root가 소유하므로, 일반 사용자로 실행되는 백업 작업은 root에게는 완벽하게 읽기 가능한 경로임에도 Permission denied 오류를 겪게 됩니다. 고정된 소유자가 필요한 경우, SSH 키, NFS 내보내기, 다른 서비스가 읽어야 하는 파일 등이 있다면 정적 사용자를 사용해야 합니다.

파일 시스템 구성: ProtectSystem, ProtectHome, PrivateTmp

ProtectSystem=는 세 가지 값을 가집니다. yes/usr와 부팅 디렉터리를 읽기 전용으로 마운트합니다. full/etc을 추가합니다. strict은 커널 API 디렉터리인 /dev, /proc, /sys을 제외한 전체 계층 구조를 읽기 전용으로 마운트하며, 이 디렉터리들은 다른 지시어로 처리됩니다. strict에서 시작하여 필요한 부분만 허용하십시오. 느슨하게 시작했다가 나중에 조이는 작업은 실제로 이루어지지 않기 때문입니다.

허용할 경로는 ReadWritePaths=/srv/notes/uploads입니다. 생각보다 적은 수만 필요합니다. StateDirectory=, LogsDirectory=, CacheDirectory=, RuntimeDirectory=ProtectSystem=strict 아래에서 자동으로 쓰기 가능 상태로 유지되므로, 예제 유닛에는 ReadWritePaths= 줄이 전혀 없습니다. strict를 사용하면 /tmp도 읽기 전용이 되지만, PrivateTmp=yes이 유닛에 자체적인 쓰기 가능 디렉터리를 제공하는 경우는 예외입니다.

영향을 받는 기능. 해당 경로 외부에서 쓰기를 시도하면 Read-only file system 오류가 발생합니다. 자체 설정을 /etc에 저장하거나, PID 파일을 /run에 직접 생성하거나, 플러그인을 /opt에 압축 해제하는 애플리케이션은 모두 이 제한에 걸립니다. 오류 메시지에서 경로를 확인하여 해당 경로만 추가하십시오. 상위 디렉터리를 추가해서는 안 됩니다. ReadWritePaths=에 나열된 경로가 존재하지 않으면 경고가 아닌 시작 실패로 처리되므로, 선택적인 경로는 하이픈을 접두사로 붙이십시오: ReadWritePaths=-/srv/notes/uploads.

읽기 전용이 곧 숨김을 의미하지는 않습니다. ProtectSystem=strict 아래에서 서비스는 여전히 /etc/passwd를 읽을 수 있으며, 다른 애플리케이션에 속한 모든 전체 읽기 가능 비밀 정보도 읽을 수 있습니다. InaccessiblePaths=/etc/ssh /srv/otherapp은 유닛의 뷰에서 하위 트리를 완전히 제거합니다. 서비스 자체의 비밀 정보는 LoadCredential=dbpass:/etc/notes/dbpass을 사용하여 해당 서비스만 읽을 수 있는 유닛별 디렉터리로 파일을 복사하며, 애플리케이션은 이를 $CREDENTIALS_DIRECTORY 아래에서 찾습니다.

ProtectHome=yes/home, /root, /run/user을 비어 있는 것처럼 보이게 합니다. 웹 서비스는 홈 디렉터리에 접근할 이유가 없으며, 이 설정은 경로 탐색(path traversal) 버그가 /root/.ssh에 도달하는 것을 방지합니다. read-onlytmpfs는 더 완화된 값입니다. 데이터가 홈 디렉터리에 위치하는 모든 애플리케이션은 이 설정으로 인해 동작이 중단되며, 이는 /home/app 아래에 직접 설치된 많은 애플리케이션에 해당합니다. 데이터를 /var/lib로 옮기거나, ProtectHome=read-only를 설정하고 보안상의 이점을 일부 포기하십시오.

PrivateTmp=yes은 서비스가 시작될 때 생성되고 종료될 때 삭제되는 전용 /tmp/var/tmp를 제공합니다. 이는 서비스 간의 임시 파일 경합 문제를 완전히 해결하며, 서비스가 충돌하더라도 시스템의 모든 사용자가 나열할 수 있는 디렉터리에 비밀 정보가 남지 않게 합니다.

영향을 받는 기능. /tmp을 공유 지점으로 사용하는 모든 항목입니다. /tmp/mysql.sock을 통해 MySQL에 접근하도록 설정된 서비스는 이제 Can't connect to local MySQL server through socket '/tmp/mysql.sock' 오류를 보고합니다. 데이터베이스는 호스트의 /tmp에 소켓을 생성했지만, 서비스는 자신의 내부 디렉터리를 찾고 있기 때문입니다. 이를 127.0.0.1이나 /run 아래의 실제 소켓 경로로 지정하십시오. 디버깅 시에도 동일한 문제가 발생합니다. 서비스가 /tmp에 기록한 파일은 셸의 /tmp에 나타나지 않습니다. 내부를 확인하려면 서비스의 마운트 네임스페이스로 진입해야 합니다.

pid=$(systemctl show --property=MainPID --value notes.service)
sudo nsenter --target "$pid" --mount ls -l /tmp

PrivateDevices=yes/dev/dev/null, /dev/zero, /dev/urandom과 같은 소규모 의사 장치(pseudo device) 세트로 대체합니다. 물리적 장치는 아예 존재하지 않게 됩니다. 디스크 노드, /dev/kvm, /dev/net/tun, 사운드 카드, GPU가 모두 사라지므로 하드웨어 트랜스코딩을 수행하는 미디어 서버는 /dev/dri/renderD128을 열지 못해 소프트웨어 방식으로 전환하거나 종료됩니다. 서비스가 특정 장치 노드를 반드시 사용해야 한다면 해당 유닛에서 PrivateDevices=를 끄고 DeviceAllow=/dev/dri/renderD128 rw으로 노드 이름을 지정하십시오. 이는 시스템의 모든 장치를 허용하는 기본값보다 훨씬 안전합니다.

서비스가 가질 수 있는 보안 설정: NoNewPrivileges와 capabilities

NoNewPrivileges=yes는 커널이 절대 해제하지 않는 프로세스 플래그를 설정합니다. 이 설정이 적용된 순간부터 해당 프로세스와 그 하위 프로세스는 setuid 바이너리나 파일 capability를 통해 권한을 획득할 수 없습니다. 이는 유닛 파일에서 가장 가치 있는 한 줄이며, 대부분의 로컬 권한 상승 공격 경로를 첫 단계에서 차단합니다.

작동하지 않는 경우. 서비스 내부에서 sudo를 호출하는 모든 작업은 실패하며, sudo: effective uid is not 0, is /usr/bin/sudo on a file system with the 'nosuid' option set or an NFS file system without root privileges? 오류가 출력됩니다. unix_chkpwd을 실행하는 PAM(pluggable authentication modules) 비밀번호 검증도 같은 이유로 실패하며, newuidmap이 필요한 루트리스 컨테이너 도구도 마찬가지입니다. 서비스가 이러한 기능에 의존한다면, 해당 지시어를 제거하기보다 의존성을 제거하십시오.

CapabilityBoundingSet=는 유닛 내 프로세스가 가질 수 있는 capability를 제한하며, 값을 비워두면 모든 권한을 제거합니다. 이미 비루트(non-root) 사용자로 실행 중인 서비스라면 이는 첫 번째가 아닌 두 번째 잠금 장치 역할을 합니다. NoNewPrivileges=yes이 일반적인 capability 획득 방식을 차단하기 때문입니다. 두 설정은 실패 방식이 다르므로, 이중으로 방어하기 위해 둘 다 유지하는 것이 좋습니다.

웹 서비스가 종종 필요로 하는 유일한 capability는 1024 미만의 포트를 사용하기 위한 CAP_NET_BIND_SERVICE입니다. 비루트 프로세스는 단순히 허용되는 것만으로는 부족하며 권한을 부여받아야 하므로, 두 줄 모두 필요합니다.

CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

systemd는 권한을 낮추기 전에 ambient capabilities를 적용하므로, 이 설정은 NoNewPrivileges=yes와 함께 정상적으로 작동합니다. 이 설정이 없으면 서비스가 시작된 후 listen tcp :443: bind: permission denied과 같이 포트 관련 오류를 내며 종료됩니다. 대부분의 VPS 환경에서는 127.0.0.1:8080에 바인딩하고 443 포트는 Nginx나 Caddy가 담당하게 하여, 유닛에서 해당 capability를 완전히 제거하는 것이 더 나은 해결책입니다. ~를 앞에 붙이면 목록이 반전되어, CapabilityBoundingSet=~CAP_SYS_ADMIN은 해당 항목을 차단하고 나머지를 허용합니다. 허용 방식(allow list)을 사용하는 것을 권장합니다. 거부 방식(deny list)은 새로운 capability가 계속 추가되므로 시간이 지날수록 관리가 어려워집니다.

커널이 노출하는 정보

ProtectKernelTunables=yes은 해당 유닛에 대해 /proc/sys, /sys 및 그 하위의 쓰기 가능한 파일들을 읽기 전용으로 만듭니다. 이 설정은 시작 시점에 sysctl을 설정하는 모든 서비스를 중단시킵니다. 시작 스크립트는 sysctl: setting key "vm.max_map_count": Read-only file system을 출력하고 종료됩니다. 값을 /etc/sysctl.d/에 넣으십시오. 이것이 올바른 위치이며 재부팅 후에도 설정이 유지됩니다. 그 후 해당 지시어는 그대로 두십시오.

ProtectKernelModules=yes는 모듈 로딩을 차단합니다. modprobe을 실행하는 유닛은 modprobe: ERROR: could not insert 'nf_conntrack': Operation not permitted 오류를 발생시킵니다. 모듈은 서비스 내부가 아닌 /etc/modules-load.d/를 통해 부팅 시점에 로드하십시오.

ProtectKernelLogs=yesdmesg 권한을 제거합니다. ProtectControlGroups=yes/sys/fs/cgroup를 읽기 전용으로 만듭니다. 컨테이너 런타임이나 자체 cgroup을 관리하는 모든 소프트웨어는 이를 즉시 감지합니다. ProtectProc=invisible/proc에서 다른 사용자의 프로세스를 숨깁니다. 따라서 침해된 서비스는 다른 데몬의 명령줄이나 누군가 전달한 비밀번호를 읽을 수 없습니다. /proc를 탐색하는 모니터링 에이전트의 경우 이 설정을 꺼야 합니다.

RestrictNamespaces=yes은 서비스가 새로운 네임스페이스를 생성하는 것을 막습니다. 이는 컨테이너 런타임에 필요한 기능이자 공격자가 탈출을 시도할 때 사용하는 기능이기도 합니다. LockPersonality=yes는 커널 실행 도메인 변경을 차단하며, RestrictSUIDSGID=yes는 서비스가 setuid 파일을 생성하지 못하게 합니다. 두 설정 모두 비용이 낮으며 일반적인 애플리케이션을 중단시키는 경우는 거의 없습니다.

서비스가 통신할 수 있는 대상

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6은 로컬 소켓과 IPv4 및 IPv6를 허용하며, 그 외의 모든 통신 시도는 socket()을 통해 EAFNOSUPPORT 오류를 발생시킵니다. 이는 seccomp 필터 방식으로 동작하므로 현재 사용 중인 모든 VPS를 포함하는 x86-64 및 arm64 아키텍처에서 작동합니다.

작동하지 않게 되는 경우. 예상보다 훨씬 빈번하게 AF_NETLINK가 발생합니다. glibc의 getifaddrs()은 netlink 소켓을 사용하며, Go, Java, .NET 런타임의 인터페이스 열거 경로 또한 마찬가지입니다. 따라서 자신의 IP 주소만 확인하려던 서비스도 OSError: [Errno 97] Address family not supported by protocol 또는 해당 언어의 대응 오류로 인해 종료됩니다. 이런 상황이 발생하면 허용 목록을 AF_UNIX AF_INET AF_INET6 AF_NETLINK로 변경해야 하며, 이는 기본값보다는 훨씬 좁은 범위입니다. AF_PACKET은 원시 패킷 캡처(raw packet capture)에 필요한 권한이므로, 그 외의 다른 서비스에는 절대 부여해서는 안 됩니다.

IPAddressAllow=localhost를 사용하는 IPAddressDeny=any는 다른 메커니즘을 따릅니다. 이는 유닛의 cgroup에 연결된 BPF 필터입니다. 이 설정은 유닛 단위로 적용되며 nft list ruleset에는 나타나지 않습니다. 따라서 같은 호스트의 데이터베이스에만 접근해야 하는 서비스에는 적합하지만, 나중에 해당 서버를 디버깅하는 관리자에게는 혼란을 줄 수 있습니다. cgroup BPF를 지원하지 않는 커널에서 systemd는 해당 유닛이 IP 방화벽을 구성하려 하지만 로컬 시스템이 BPF/cgroup 방화벽을 지원하지 않는다는 로그를 남깁니다. 이 경우 규칙은 아무런 동작을 하지 않으므로, 추측하기보다는 반드시 저널 로그를 확인해야 합니다.

보안 강화된 유닛이 시작되지 않는 이유는 무엇입니까?

위의 모든 지시어는 정상 작동하던 서비스를 실패하게 만들 수 있으며, 실패 현상은 원인이 된 지시어와 전혀 관련이 없어 보이는 경우가 많습니다. 문제 해결 과정은 항상 동일합니다. 저널을 읽고, 지시어 하나만 완화한 뒤, 다시 테스트하십시오.

sudo systemctl restart notes.service
systemctl status notes.service
sudo journalctl -u notes.service -n 50 --no-pager

샌드박스를 지정하는 행은 다음과 같습니다:

notes.service: Failed to set up mount namespacing: No such file or directory
notes.service: Failed at step NAMESPACE spawning /opt/notes/bin/notes: No such file or directory
notes.service: Main process exited, code=exited, status=226/NAMESPACE

226/NAMESPACE은 systemd가 파일 시스템 뷰를 구성하지 못했음을 의미하며, 따라서 바이너리는 전혀 실행되지 않았습니다. 일반적인 원인은 ReadWritePaths=, BindPaths= 또는 InaccessiblePaths=에 존재하지 않는 경로가 포함된 경우입니다. 228/SECCOMPSystemCallFilter= 또는 SystemCallArchitectures= 적용 실패를 가리킵니다. code=killed, status=31/SYS는 다른 경우입니다. 프로세스가 시작된 후 필터에 의해 차단된 시스템 호출을 수행하여 커널이 프로세스를 강제 종료한 것입니다. 작업하는 동안 시작되지 않는 유닛에 대한 systemd 종료 코드 참조를 열어 두십시오. 종료 코드는 네임스페이스 문제와 애플리케이션 문제를 구분하는 가장 빠른 방법이기 때문입니다.

결과를 통해 원인을 파악할 수 있도록 드롭인(drop-in) 파일을 사용하여 지시어를 한 번에 하나씩 완화하십시오. sudo systemctl edit notes.service를 실행하고 다음 한 줄을 입력하십시오:

[Service]
ProtectSystem=full

재시작하십시오. 서비스가 시작된다면, 삭제하는 대신 어떤 지시어를 조정해야 할지 알 수 있습니다. 다시 strict으로 되돌리고, 애플리케이션이 실제로 필요로 하는 경로에 대해 ReadWritePaths= 행을 추가한 뒤 다시 시작하십시오. 서비스가 시작되지 않는다는 이유로 블록 전체를 삭제하면, 결국 보호 기능이 전혀 없고 아무도 작성 이유를 기억하지 못하는 주석만 남은 유닛이 됩니다.

서비스를 포함하지 않고 샌드박스를 테스트하려면, 샌드박스 내부에서 셸을 실행하십시오:

sudo systemd-run --pty -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /bin/bash

해당 셸 내부에서 touch /etc/testRead-only file system를 반환하며, ls /home은 아무것도 표시하지 않습니다. 이는 애플리케이션이 무엇을 보게 될지 확인하는 가장 빠른 방법입니다. 직접 명령어를 실행하여 어떤 명령어가 실패하는지 관찰할 수 있기 때문입니다.

어떤 실패 모드는 오류를 전혀 생성하지 않습니다. 지시어의 철자가 틀리면 경고만 발생하며, 서비스는 해당 지시어 없이 시작됩니다:

/etc/systemd/system/notes.service:14: Unknown key name 'ProtectSytem' in section 'Service', ignoring.

유닛은 실행되지만 샌드박스는 적용되지 않으며, 더 이상의 경고도 나타나지 않습니다. 다음 두 명령어로 이를 포착할 수 있습니다. sudo systemd-analyze verify /etc/systemd/system/notes.service은 요청 시 동일한 경고를 출력하며, systemctl show는 실행 중인 서비스가 실제로 수신한 내용을 출력합니다:

systemctl show notes.service -p User -p ProtectSystem -p PrivateTmp -p NoNewPrivileges

작성한 내용은 strict인데 ProtectSystem=no이 반환된다면, 유닛은 의도한 대로 로드되지 않은 것입니다.

systemd-analyze security를 체크리스트로 활용하기

sudo systemd-analyze security notes.service는 유닛이 사용할 수 있는 모든 샌드박스 설정과 해당 유닛의 현재 적용 상태, 그리고 각 항목에 대한 평가 결과를 출력합니다. 인자 없이 실행하면 시스템에 로드된 모든 서비스를 나열하며, --offline=true 뒤에 경로를 추가하면 유닛 파일을 설치하기 전에 미리 검사할 수 있습니다.

출력 내용을 할 일 목록으로 활용하십시오. 도구가 경고하는 항목을 하나씩 확인하며 "이 서비스에 해당 권한이 정말 필요한가?"라는 질문을 던져야 합니다. 대부분의 경우 대답은 '아니오'이며, 이때 해당 설정을 추가하면 됩니다. 물론 '예'인 경우도 있습니다. 미디어 서버는 장치 노드에 접근해야 하고, 백업 에이전트는 /home을 읽어야 합니다. 이러한 항목은 계속 경고 상태로 남게 되지만, 이는 실패가 아니라 올바른 결과입니다.

마지막에 표시되는 종합 점수는 위 항목들을 합산한 수치일 뿐입니다. 이 도구는 해당 서비스가 어떤 기능을 수행하는지, 어떤 데이터를 다루는지, 혹은 애플리케이션 자체에 취약점이 있는지 알지 못합니다. 유닛 파일이 도구의 모든 검사 항목을 통과하더라도 서버에서 가장 취약한 지점이 될 수 있습니다. 이 도구는 소프트웨어의 노출도가 아닌 유닛 파일의 설정 노출도를 측정하기 때문입니다. 직접 호스팅하는 비밀번호 관리자를 예로 들면, Vaultwarden 유닛은 모든 항목을 통과할 수 있지만, 관리자 토큰과 백업 파일이 금고의 보안을 결정하는 핵심 요소라는 사실은 변하지 않습니다. 점수 올리기에만 급급하면 이해하지 못한 지시어를 무분별하게 추가하게 됩니다. 이는 패키지 업데이트 후 서비스가 중단되는 원인이 되며, 정작 왜 해당 설정이 추가되었는지 설명할 수 있는 사람은 아무도 남지 않게 됩니다.

샌드박싱의 한계

이 지시어들은 서비스가 접근할 수 있는 범위를 제어합니다. 서비스가 소비할 수 있는 자원량과는 무관하므로, 샌드박싱이 완벽하게 적용된 유닛이라도 서버의 모든 CPU 코어와 메모리를 점유할 수 있습니다. 이는 별도의 설정 항목이며, systemd 서비스의 CPU 및 메모리 제한 가이드에서 다룹니다.

또한, 이 지시어들은 강제 접근 제어(Mandatory Access Control)를 대체하지 않습니다. 네임스페이스는 유닛별로 유닛 파일을 작성하는 사람이 설정하지만, SELinux는 시스템 전체에 걸쳐 하나의 정책을 강제합니다. 두 기능은 상호 보완적이며, 어느 하나가 다른 하나를 대체할 수 없습니다.

마지막으로, 이 설정들은 systemd가 해당 유닛 내부에서 시작한 프로세스에만 적용됩니다. 소켓을 통해 보조 데몬에 작업을 전달하는 서비스의 경우, 해당 보조 데몬까지 샌드박싱되지는 않습니다. 동일한 블록을 해당 유닛 파일에도 작성해야 하며, 파일 내용만 신뢰하기보다는 systemctl show을 사용하여 결과를 직접 확인하십시오.

FAQ

ProtectSystem=strict 설정은 정확히 무엇을 읽기 전용으로 만듭니까?

PrivateDevices=, ProtectKernelTunables=, ProtectControlGroups=가 적용되는 /dev, /proc, /sys를 제외한 전체 파일 시스템 계층 구조입니다. 여기에는 /etc, /var, /srv, /opt, /tmp가 포함됩니다. systemd가 자동으로 추가하는 예외는 StateDirectory=, CacheDirectory=, LogsDirectory=, RuntimeDirectory=와 같이 시스템이 관리하는 디렉터리들입니다. 서비스가 쓰기 작업을 수행해야 하는 다른 모든 경로는 ReadWritePaths= 항목에 명시적으로 추가해야 합니다. 읽기 전용이라고 해서 읽을 수 없다는 뜻은 아니므로, InaccessiblePaths=에 나열하지 않는 한 서버 내의 다른 비밀 파일은 서비스가 여전히 읽을 수 있습니다.

서비스가 status=226/NAMESPACE 오류로 실패하는 이유는 무엇입니까?

systemd가 마운트 네임스페이스를 생성하지 못해 실행 파일이 시작되지 않은 경우입니다. 해당 로그 바로 위에는 보통 Failed to set up mount namespacing: No such file or directory이라는 메시지가 표시됩니다. 대부분의 경우 ReadWritePaths=, BindPaths=, InaccessiblePaths=에 지정된 경로가 디스크에 존재하지 않기 때문입니다. 해당 디렉터리를 생성하거나, 경로 앞에 하이픈(ReadWritePaths=-/srv/notes/uploads)을 붙여 해당 경로가 없을 경우 systemd가 무시하도록 설정하십시오. 경로가 실제로 존재한다면 유닛 파일의 오타를 확인하고, 드롭인 파일에 의해 의도치 않은 설정이 추가되었을 수 있으므로 systemctl cat notes.service 명령으로 최종 설정을 확인하십시오.

PrivateTmp를 사용하는 서비스가 /tmp를 통해 파일을 공유할 수 있습니까?

아니요, 그것이 바로 이 기능의 목적입니다. 서비스는 실행되는 동안에만 존재하는 별도의 /tmp/var/tmp을 할당받으므로, 호스트의 /tmp에 다른 프로세스가 생성한 소켓이나 파일은 보이지 않습니다. /tmp/mysql.sock에 위치한 데이터베이스 소켓이 흔히 발생하는 문제이며, 해결 방법은 127.0.0.1을 통해 연결하거나 클라이언트가 /run 아래의 실제 소켓을 가리키도록 하는 것입니다. 서비스의 임시 파일을 확인하려면 systemctl show --property=MainPID --value에서 메인 PID를 가져온 뒤 sudo nsenter --target <pid> --mount를 사용하여 해당 서비스의 마운트 네임스페이스로 진입하십시오.

샌드박스 처리된 서비스가 root 권한 없이 443 포트를 수신할 수 있습니까?

root 계정 전체를 부여하는 대신 하나의 capability만 부여하십시오. AmbientCapabilities=CAP_NET_BIND_SERVICECapabilityBoundingSet=CAP_NET_BIND_SERVICE을 함께 설정하고 User= 또는 DynamicUser=yes을 유지하면, 프로세스는 낮은 번호의 포트를 바인딩할 수 있게 되며 다른 권한은 갖지 않습니다. 바운딩 세트(bounding set)만 설정하는 것이 흔한 실수인데, 이 경우 capability가 허용은 되지만 실제로는 부여되지 않아 포트 관련 권한 오류로 서비스가 종료됩니다. 이미 리버스 프록시를 운영 중인 VPS라면, 서비스에서 127.0.0.1:8080를 바인딩하고 443 포트는 nginx가 담당하게 하는 것이 더 깔끔한 해결책이며, 이 경우 유닛에 어떠한 capability도 필요하지 않습니다.

시스템 사용자를 생성하는 대신 DynamicUser를 사용해야 합니까?

서비스의 모든 데이터가 StateDirectory=, CacheDirectory=, LogsDirectory= 내부에 저장되는 경우 사용하십시오. 대부분의 소규모 자가 호스팅 웹 애플리케이션에 적합합니다. 서비스가 실행되는 동안에만 존재하는 UID를 얻게 되며, PrivateTmp=, ProtectSystem=strict, ProtectHome=read-only, RemoveIPC=이 자동으로 활성화됩니다. UID가 일정하게 유지되어야 하는 경우(해당 디렉터리 외부의 파일 소유권, SSH 키, NFS 마운트, 동일한 데이터를 읽는 두 번째 프로세스가 있는 경우)에는 정적 시스템 사용자를 사용하십시오. DynamicUser=yes를 사용하면 데이터가 실제로 /var/lib/private/notes에 저장되며, 해당 디렉터리는 root 소유의 0700 모드로 설정되므로 root가 아닌 사용자의 백업 작업이 실패할 수 있다는 점을 기억하십시오.