Contents
see List왜 systemd 단위 파일에서 보안을 줄여야 하는가
운영 서버에서 웹 애플리케이션, 배치 작업, 내부 API를 systemd 서비스로 실행하는 경우가 많습니다. 문제는 서비스가 한 번 침해되었을 때 어디까지 피해가 번질 수 있느냐입니다. 애플리케이션 코드에 취약점이 있더라도 프로세스가 운영체제 설정 파일을 수정하지 못하고, 필요 없는 네트워크 주소 체계를 열지 못하고, 임시 디렉터리나 커널 인터페이스에 마음대로 접근하지 못한다면 사고 범위는 크게 줄어듭니다. systemd의 보안 옵션은 별도 보안 제품을 붙이기 전에 단위 파일만으로 적용할 수 있는 기본 방어선입니다.
이 문서는 이미 동작 중인 일반적인 Node.js, Java, Go, Python API 서비스를 기준으로 합니다. 핵심은 모든 옵션을 한 번에 켜는 것이 아니라 서비스가 실제로 필요한 권한을 먼저 적고, 나머지를 닫은 뒤, 로그와 상태 디렉터리처럼 필요한 쓰기 경로만 명시적으로 열어 주는 방식입니다. 운영 중인 서비스라면 먼저 스테이징 서버에 적용하고, systemctl status, 애플리케이션 로그, 헬스 체크를 같이 확인해야 합니다.
기본 서비스 파일부터 점검하기
아래 예시는 /opt/myapp에 배포된 API 서버를 /etc/systemd/system/myapp.service로 실행하는 구성입니다. DynamicUser=yes는 실행 시점에 임시 사용자와 그룹을 만들어 서비스를 실행합니다. 서비스 전용 계정을 미리 만들 필요가 줄어들고, 다른 서비스와 계정을 공유하는 실수를 방지할 수 있습니다. 단, 영구 파일을 써야 한다면 StateDirectory, LogsDirectory, CacheDirectory 같은 디렉터리 지시어를 함께 사용해 systemd가 적절한 소유권으로 경로를 준비하게 만드는 것이 좋습니다.
[Unit]
Description=MyApp API service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5
DynamicUser=yes
StateDirectory=myapp
LogsDirectory=myapp
Environment=NODE_ENV=production
Environment=PORT=8080
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/myapp /var/log/myapp
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
PrivateDevices=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
[Install]
WantedBy=multi-user.target
ProtectSystem=strict는 파일시스템 대부분을 읽기 전용으로 다루게 만들고, ReadWritePaths로 지정한 경로만 쓰기 가능하도록 여는 방식입니다. 따라서 애플리케이션이 업로드 파일, SQLite 파일, 캐시, 임시 결과물을 어디에 쓰는지 먼저 파악해야 합니다. 임의로 /opt/myapp에 로그를 쓰는 구조라면 배포 디렉터리와 런타임 데이터가 섞여 있으므로 먼저 /var/lib/myapp, /var/log/myapp 같은 운영 경로로 분리하는 편이 좋습니다.
옵션별 적용 기준
NoNewPrivileges=yes는 서비스와 자식 프로세스가 setuid, 파일 capability 같은 방식으로 새 권한을 얻지 못하게 막습니다. 일반 애플리케이션 서버는 켜는 쪽이 기본값이어야 합니다.PrivateTmp=yes는 서비스에 별도 /tmp와 /var/tmp 공간을 제공합니다. 다른 서비스가 만든 임시 파일을 읽거나 덮어쓰는 사고를 줄입니다.ProtectHome=yes는 /home, /root, /run/user 접근을 제한합니다. 사용자 홈 디렉터리의 SSH 키나 개인 설정이 서비스에서 보일 이유가 없다면 켜야 합니다.PrivateDevices=yes는 물리 장치 접근을 줄입니다. 일반 웹 서비스가 블록 디바이스, 시리얼 장치, 특수 장치 파일을 직접 만질 필요는 거의 없습니다.RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6는 사용할 주소 체계를 제한합니다. 일반 API 서버는 유닉스 소켓, IPv4, IPv6 정도면 충분한 경우가 많습니다.MemoryDenyWriteExecute=yes는 쓰기와 실행 권한을 동시에 갖는 메모리 매핑을 막아 일부 코드 주입 공격의 여지를 줄입니다. JIT 런타임이나 일부 언어 실행 환경에서는 충돌할 수 있으므로 적용 후 테스트가 필요합니다.
적용 전후 확인 명령
단위 파일을 수정한 뒤에는 데몬 설정을 다시 읽고, 재시작 전에 구문 문제를 먼저 확인합니다. 특히 DynamicUser와 디렉터리 지시어를 함께 쓰는 경우 실제 런타임 경로가 기대대로 생성되는지 확인해야 합니다. 다음 명령은 배포 자동화 스크립트에 그대로 넣기보다 운영자가 한 번씩 수동 점검할 때 유용합니다.
sudo systemctl daemon-reload
sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl restart myapp.service
sudo systemctl status myapp.service --no-pager
sudo journalctl -u myapp.service -n 100 --no-pager
sudo systemd-analyze security myapp.service --no-pager
systemd-analyze security는 서비스 단위의 sandboxing 설정을 분석하고 노출도를 숫자로 보여 줍니다. 점수가 낮을수록 systemd 관점의 제한이 강하다는 뜻입니다. 이 점수는 애플리케이션 자체의 인증, 인가, 입력 검증 품질을 대신 평가하지 않습니다. 그래서 점수를 목표로 삼기보다 어떤 항목이 열려 있는지 확인하고, 서비스 요구사항과 맞지 않는 권한을 하나씩 줄이는 용도로 쓰는 것이 현실적입니다.
실패했을 때 빠르게 되돌리는 방법
하드닝 옵션은 강력하지만 서비스별 특성을 무시하면 장애를 만들 수 있습니다. 예를 들어 이미지 처리 서비스가 /tmp 공유 파일에 의존하거나, 브라우저 자동화 작업이 사용자 홈의 폰트 캐시를 읽거나, JVM 에이전트가 특정 메모리 실행 정책을 요구할 수 있습니다. 이럴 때는 전체 설정을 되돌리기보다 방금 추가한 옵션을 그룹 단위로 나누어 원인을 좁히는 것이 빠릅니다.
# 임시로 override 파일을 열어 특정 옵션을 완화한다.
sudo systemctl edit myapp.service
# 예: 파일시스템 제한 문제를 확인할 때만 임시 완화
[Service]
ProtectSystem=full
ReadWritePaths=/var/lib/myapp /var/log/myapp /tmp/myapp-work
# 적용 및 확인
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
sudo journalctl -u myapp.service -f
systemctl edit로 만든 override는 /etc/systemd/system/myapp.service.d/override.conf에 저장됩니다. 장애 대응 중에는 어떤 옵션을 왜 풀었는지 주석으로 남기고, 원인이 해결되면 다시 좁히는 절차를 작업 티켓에 기록해야 합니다. 보안 설정은 한 번 켜는 행위보다 운영 중에도 계속 유지되는 관리 절차가 더 중요합니다.
운영 적용 순서
- 서비스가 실제로 쓰는 파일 경로, 포트, 외부 명령, 임시 디렉터리를 목록으로 만든다.
NoNewPrivileges,PrivateTmp,ProtectHome처럼 충돌 가능성이 낮은 옵션부터 스테이징에 적용한다.ProtectSystem=strict를 적용하기 전에 쓰기 경로를 /var/lib/서비스명, /var/log/서비스명, /run/서비스명처럼 분리한다.systemd-analyze security결과에서 높은 노출 항목을 확인하되 점수만 보고 무리하게 옵션을 켜지 않는다.- 재시작 후 헬스 체크, 주요 API, 배치 작업, 로그 기록, 파일 업로드와 다운로드를 반드시 확인한다.
- 문제가 생기면
systemctl editoverride로 임시 완화하고, 원인 옵션을 찾은 뒤 최소 권한 원칙에 맞게 다시 좁힌다.
최종 체크리스트는 간단합니다. 서비스는 전용 사용자 또는 DynamicUser로 실행하고, 운영체제 영역은 읽기 전용으로 두며, 쓰기 경로는 명시적으로 허용하고, 필요 없는 장치·커널·주소 체계 접근은 닫습니다. 마지막으로 단위 파일 변경은 코드 배포와 같은 수준으로 리뷰하고, 적용 결과를 journalctl과 systemd-analyze security로 확인하면 됩니다.