Docker 컨테이너의 Codex / Bubblewrap 샌드박스 권한 구성
대상: Ubuntu/Debian 기반 VPS에서 Docker Compose로 실행 중인 CloudCLI + Codex.
목표:seccomp:unconfined,apparmor:unconfined,privileged: true, 컨테이너CAP_SYS_ADMIN추가 없이 사용자 정의 보안 프로파일을 적용한다.
상태: 설정 예시이며 실제 호스트에서 성공이 검증된 구성은 아니다. 특히RTM_NEWADDR오류는 아래 두 프로파일만으로 반드시 해결되지 않는다.
1. 문제 배경과 원인
Codex의 Linux 샌드박스는 Bubblewrap(bwrap)을 이용해 기존 컨테이너 안에서 추가 격리 환경을 만든다. 이 과정에는 사용자·마운트·네트워크 네임스페이스 생성, 파일시스템 마운트, 루프백 인터페이스 설정 등이 포함될 수 있다. Docker 컨테이너는 이미 자체 격리와 커널 보안 정책을 적용받으므로, 내부에서 다시 네임스페이스를 구성할 때 차단될 수 있다.
| 증상 | 뜻 | 주요 점검 대상 |
|---|---|---|
unshare: unshare failed: Operation not permitted | 네임스페이스 생성/변경 거부 | seccomp, AppArmor, 커널 user namespace 정책 및 capability |
mount: ... cannot mount ... | 마운트 작업 거부 | seccomp, AppArmor mount 규칙, 해당 네임스페이스의 CAP_SYS_ADMIN 및 마운트 조건 |
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted | 새 네트워크 네임스페이스에서 루프백 주소 할당 거부 | CAP_NET_ADMIN 가용성, AppArmor userns 전환/거부 로그, 네트워크 네임스페이스 정책 |
.git/index.lock: Read-only file system | Git 저장소가 해당 프로세스에서 읽기 전용으로 보임 | bind mount 및 Codex가 만드는 샌드박스의 쓰기 가능 경로. 위 문제와 별개 |
중요: RTM_NEWADDR는 시스템 콜 이름이 아니라 netlink 메시지다. 따라서 seccomp에서 mount 및 unshare를 허용하는 것만으로 해결된다고 단정하면 안 된다. 또한 capability,는 AppArmor의 중재 차원에서 허용하는 구문이지 Docker에서 삭제된 capability를 복원하지 않는다.
네임스페이스와 보안 검사의 관계
- Docker의 seccomp 정책이 시스템 콜 실행을 필터링한다.
- AppArmor 정책이 프로세스의 파일·마운트·capability·user namespace 등의 작업을 중재한다.
- 리눅스 커널은 해당 사용자/네임스페이스에 필요한 capability 및 일반적인 권한 요건을 검사한다.
- Ubuntu 계열의 unprivileged user namespace 관련 AppArmor 정책이 추가로 적용될 수 있다.
즉, 한 정책에서 허용해도 다른 단계에서 거부될 수 있다.
2. seccomp란?
seccomp(secure computing mode) 는 리눅스 커널의 시스템 콜 필터링 기능이다. Docker는 기본적으로 seccomp 프로파일을 적용하며, 어떤 시스템 콜 또는 인자 조합을 허용하거나 오류(EPERM)로 거부할지 설정한다. 일반적인 Docker JSON 프로파일에서는 defaultAction: SCMP_ACT_ERRNO로 기본 차단한 뒤 허용할 호출을 열거한다.
- 적용 범위: 시스템 콜 수준.
- 이 문서의 변경: Moby 기본 프로파일에 있는 네임스페이스·마운트 관련 호출 일부를 별도의
SCMP_ACT_ALLOW규칙으로 이동. - 한계: seccomp 허용은 실제 커널 capability를 부여하지 않는다.
3. AppArmor란?
AppArmor는 리눅스의 강제 접근 제어(MAC) 프레임워크다. 프로세스별 프로파일을 통해 파일, 마운트, 네트워크, capability, ptrace, signal 등의 접근을 중재한다. Docker는 지정된 프로파일 이름을 사용해 컨테이너 프로세스를 제한할 수 있다.
- 적용 범위: 프로세스에 부착된 MAC 프로파일.
- 이 문서의 변경: namespace 내부의 mount 작업을 허용하면서
/proc,/sys의 일부 민감 경로 쓰기 등을 거부. - 한계: 광범위한
file, network, capability, mount,규칙은 엄격한 최소 권한 정책이 아니다.deny로 나열된 경로만으로 전체 커널 인터페이스가 보호된다고 볼 수도 없다.
4. 생성되는 파일과 경로
| 호스트 경로 | 역할 | 생성 방식 |
|---|---|---|
/etc/docker/seccomp/codex-base.json | Moby upstream 기본 seccomp 원본 복사본 | curl -o |
/etc/docker/seccomp/codex-bwrap.json | bwrap용 커스텀 seccomp | Python이 기본 파일을 읽고 생성 |
/etc/apparmor.d/docker-codex-bwrap | bwrap용 커스텀 AppArmor | heredoc으로 생성 |
기존 동일 경로 파일이 있으면 덮어쓴다. 이 절차는 /etc/docker/daemon.json, /etc/apparmor.d/docker, Docker의 내장 기본 seccomp 프로파일 자체를 수정하지 않는다. /etc/docker/seccomp 디렉터리가 없으면 새로 만든다.
5. 생성되는 seccomp 프로파일 내부 내용
파일: /etc/docker/seccomp/codex-bwrap.json
실제 JSON은 Moby의 약 1,200줄에 이르는 기본 파일을 기반으로 실행 시 동적으로 생성된다. 따라서 아래는 생성 JSON의 커스텀 허용 규칙과 핵심 구조를 보여주는 발췌본이다. ...로 전체 기본 규칙을 생략한 표기는 유효한 JSON이 아니므로 그대로 파일에 저장해서는 안 된다. 완전한 JSON은 다음 절의 생성 스크립트가 작성한다.
{
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"archMap": [],
"syscalls": [
{
"names": [
"clone",
"clone3",
"fsconfig",
"fsmount",
"fsopen",
"mount",
"mount_setattr",
"move_mount",
"open_tree",
"pivot_root",
"setns",
"umount",
"umount2",
"unshare"
],
"action": "SCMP_ACT_ALLOW"
}
]
}위 코드는 구조 설명용 축약본이다.
archMap: []과 단일syscalls목록은 실제 파일의 내용이 아니다. 생성되는 실제 파일에는 Moby 원본의 전체archMap및 나머지syscalls규칙이 유지된다. 원본에defaultErrnoRet등이 없거나 수정된 경우 실제 생성본도 그 변경을 따른다.
변환 알고리즘은 원본의 모든 syscalls[].names에서 위 14개 이름을 제거하고, 해당 이름들에 대한 SCMP_ACT_ALLOW 규칙을 한 개 추가한다. 원본의 나머지 필드와 규칙은 보존한다. 이로 인해 원래 조건부 허용되던 호출까지 무조건 허용되는 보안 완화가 발생한다.
전체 seccomp 파일을 생성하는 Python 코드
import json
from pathlib import Path
directory = Path("/etc/docker/seccomp")
profile = json.loads((directory / "codex-base.json").read_text())
assert profile["defaultAction"] == "SCMP_ACT_ERRNO"
allowed = {
"clone", "clone3", "unshare", "setns",
"mount", "umount", "umount2", "pivot_root",
"fsopen", "fsconfig", "fsmount",
"move_mount", "open_tree", "mount_setattr",
}
rules = []
for rule in profile["syscalls"]:
rule["names"] = [name for name in rule["names"] if name not in allowed]
if rule["names"]:
rules.append(rule)
rules.append({
"names": sorted(allowed),
"action": "SCMP_ACT_ALLOW",
})
profile["syscalls"] = rules
(directory / "codex-bwrap.json").write_text(
json.dumps(profile, indent=2) + "\n"
)6. 생성되는 AppArmor 프로파일 내부 내용
파일: /etc/apparmor.d/docker-codex-bwrap
아래는 제공된 원본 설정 그대로의 프로파일이다. AppArmor 4 이상에서는 다음 절의 스크립트가 mount, 직전에 userns,를 추가한다.
#include <tunables/global>
profile docker-codex-bwrap flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
file,
network,
capability,
# bwrap의 mount namespace 구성
mount,
umount,
pivot_root,
# 종료 신호 및 동일 프로파일 프로세스 간 통신
signal (receive) peer=unconfined,
signal (send,receive) peer=docker-codex-bwrap,
ptrace (read,trace,readby,tracedby) peer=docker-codex-bwrap,
# 주요 커널 설정 및 민감 인터페이스 쓰기 차단
deny /proc/sys/** w,
deny /proc/sysrq-trigger rwklx,
deny /proc/kcore rwklx,
deny /proc/kmem rwklx,
deny /proc/mem rwklx,
deny /proc/keys rwklx,
deny /proc/timer_list rwklx,
deny /sys/** wklx,
deny /sys/kernel/security/** rwklx,
}AppArmor 4 이상일 때 추가되는 규칙:
userns,주의: AppArmor의 버전, 커널 ABI, userns 관련 프로파일 전환 정책에 따라 이 규칙 하나만으로 충분하지 않을 수 있다. apparmor_parser의 성공은 파싱과 로드에 성공했다는 의미이지 bwrap 작동 검증은 아니다.
7. VPS 호스트에서 설치·생성·로드
실행 위치: 컨테이너 안이 아니라 VPS 호스트. 아래 명령은 앞서 나열된 세 파일의 동일 경로를 덮어쓴다. 기존 파일이 있다면 먼저 백업한다.
sudo bash <<'SH'
set -euo pipefail
apt-get update
apt-get install -y apparmor curl python3
install -d -m 0755 /etc/docker/seccomp
curl -fsSL \
https://raw.githubusercontent.com/moby/profiles/main/seccomp/default.json \
-o /etc/docker/seccomp/codex-base.json
python3 <<'PY'
import json
from pathlib import Path
directory = Path("/etc/docker/seccomp")
profile = json.loads((directory / "codex-base.json").read_text())
assert profile["defaultAction"] == "SCMP_ACT_ERRNO"
allowed = {
"clone", "clone3", "unshare", "setns",
"mount", "umount", "umount2", "pivot_root",
"fsopen", "fsconfig", "fsmount",
"move_mount", "open_tree", "mount_setattr",
}
rules = []
for rule in profile["syscalls"]:
rule["names"] = [name for name in rule["names"] if name not in allowed]
if rule["names"]:
rules.append(rule)
rules.append({"names": sorted(allowed), "action": "SCMP_ACT_ALLOW"})
profile["syscalls"] = rules
(directory / "codex-bwrap.json").write_text(json.dumps(profile, indent=2) + "\n")
PY
cat > /etc/apparmor.d/docker-codex-bwrap <<'AA'
#include <tunables/global>
profile docker-codex-bwrap flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
file,
network,
capability,
mount,
umount,
pivot_root,
signal (receive) peer=unconfined,
signal (send,receive) peer=docker-codex-bwrap,
ptrace (read,trace,readby,tracedby) peer=docker-codex-bwrap,
deny /proc/sys/** w,
deny /proc/sysrq-trigger rwklx,
deny /proc/kcore rwklx,
deny /proc/kmem rwklx,
deny /proc/mem rwklx,
deny /proc/keys rwklx,
deny /proc/timer_list rwklx,
deny /sys/** wklx,
deny /sys/kernel/security/** rwklx,
}
AA
AA_MAJOR="$(apparmor_parser --version | head -n1 | sed -E 's/.* ([0-9]+)\..*/\1/')"
if [ "$AA_MAJOR" -ge 4 ]; then
sed -i '/^ mount,/i\ userns,' /etc/apparmor.d/docker-codex-bwrap
fi
apparmor_parser -r /etc/apparmor.d/docker-codex-bwrap
SH실행상 유의: 최초 제공된 명령을 보존했다. AppArmor parser 버전 식별 정규식이 특정 배포판 출력 형식에서 실패할 가능성이 있다. 또한
apparmor_parser명령은 배포판 패키지 구성에 따라 별도 설치가 필요할 수 있다. 설정을 적용하기 전에 호스트에서 프로파일 문법과 버전을 확인한다.
8. Docker Compose 적용
services:
cloudcli:
# 기존 image, volumes, environment, networks 유지
security_opt:
- seccomp=/etc/docker/seccomp/codex-bwrap.json
- apparmor=docker-codex-bwrap- 기존
seccomp:unconfined,apparmor:unconfined가 있다면 제거한다. - seccomp 파일 경로는 Docker 데몬이 실행되는 호스트의 경로이다.
- AppArmor는 호스트에 로드된 프로파일 이름으로 지정한다.
- 기존 설정의 네트워크·볼륨·사용자·capability 등은 별도로 검토해야 한다.
9. 재생성 및 검증
docker compose up -d --force-recreate cloudcli
docker compose exec cloudcli sh -lc '
cat /proc/self/attr/current
codex sandbox linux -- sh -c "pwd; printf \"BWRAP_OK\\n\""
'기대 결과:
docker-codex-bwrap (enforce)
...
BWRAP_OK(enforce)와 BWRAP_OK 모두 확인해야 한다. 하나만 보인다면 부분 적용 또는 샌드박스 실행 실패다. Compose 서비스 이름이 다른 경우 cloudcli를 실제 이름으로 수정한다.
문제가 계속되면 다음 명령으로 실제 거부 원인을 조사한다.
# 호스트: AppArmor 거부 기록
sudo journalctl -k --since '10 minutes ago' | grep -Ei 'apparmor|DENIED|audit'
# 호스트: 네임스페이스 제한 정책
sysctl kernel.unprivileged_userns_clone kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || true
# 컨테이너: 능력 및 실제 프로파일 확인
docker compose exec cloudcli sh -lc 'grep -E "^(CapEff|CapBnd|Seccomp|NoNewPrivs):" /proc/self/status; cat /proc/self/attr/current'
# 호스트: Docker 데몬이 컨테이너에 적용한 설정 확인
docker inspect "$(docker compose ps -q cloudcli)" --format '{{json .HostConfig.SecurityOpt}}'특히 apparmor="DENIED" capname="net_admin"가 찍히면 위 설정이 AppArmor 네임스페이스 정책까지 충분히 해결하지 못한 상태일 수 있다. 이 경우 무조건 호스트 CAP_NET_ADMIN을 부여하거나 unconfined로 바꾸는 대신, 어느 프로파일이 적용되어 차단하는지 먼저 확인한다.
10. 보안상 유의사항
- 커스텀 seccomp는 네임스페이스·마운트 시스템 콜을 광범위하게 허용한다. Docker 기본값보다 약해진다.
- AppArmor의
file, network, capability, mount,도 상당히 넓은 허용 규칙이다. 엄격한 최소 권한 프로파일이 아니다. privileged: true및 컨테이너에 대한CAP_SYS_ADMIN추가를 설정하지 않았다는 사실이 곧 권한 상승 위험이 없다는 의미는 아니다.- Moby
main브랜치의 기본 seccomp 파일은 시점에 따라 변경될 수 있다. 재현성·변경 추적이 중요하다면 특정 커밋으로 고정하고 체크섬을 보관한다. - 기존 파일을 재생성할 때는 덮어쓴다. 테스트가 완료되지 않은 구성을 보안상 안전하다고 단정하면 안 된다.
- Docker 컨테이너 외부의 Codex 샌드박스·호스트 Git 권한까지 자동으로 해결하는 구성은 아니다.
11. 원문 및 참고 자료
- Docker 공식 문서 — Seccomp security profiles: https://docs.docker.com/engine/security/seccomp/
- Moby 공식 기본 seccomp JSON: https://raw.githubusercontent.com/moby/profiles/main/seccomp/default.json
- OpenAI Codex 이슈 — AppArmor 및
RTM_NEWADDR조사: https://github.com/openai/codex/issues/29908 - OpenAI Codex 이슈 — Bubblewrap loopback 오류: https://github.com/openai/codex/issues/14919
- Anthropic sandbox-runtime 이슈 — Ubuntu AppArmor userns 제약: https://github.com/anthropics/sandbox-runtime/issues/74