The era of multi-user operating systems is long over. People today generally don’t share computing resources by having multiple users log into the same OS — we have better virtualization technology for that.
But users still serve a purpose, the most important being permission isolation. There’s a lot to say on this topic, but we’ll focus on just the most critical points. Containers introduce additional quirks around users, which we’ll also discuss.
Users and Permissions
Users and Groups
The operating system controls permissions through users — only specific users can perform specific operations.
The OS has a set of configured users, stored in the /etc/passwd file. Each user belongs to one or more groups. Users and groups have uid and gid respectively. Use the id command to see the current user’s IDs:
$ id
uid=1000(wokron) gid=1000(wokron) groups=1000(wokron) 1001(xxxxx) 1002(yyyyy)
The gid points to the user’s primary group. You can switch primary groups with the newgrp command, which starts a new shell:
$ newgrp xxxxx
$ id
uid=1000(wokron) gid=1001(xxxxx) groups=1000(wokron) 1001(xxxxx) 1002(yyyyy)
What follows omits many details, but the omitted parts are relatively rarely encountered.
When logging into the system as a user, the shell created by this login attaches the user and primary group IDs to the process. These two IDs are called the process’s credentials.
Child processes inherit their parent’s credentials. Credentials indicate which user the process is acting on behalf of.
The Superuser
A process’s user and group determine whether it has permission to perform a given operation. The classic Unix permission model distinguishes only two types of users: the superuser and ordinary users. The superuser can perform all operations the operating system provides, while ordinary users are restricted. Ordinary users’ operations cannot affect the system’s state.
The superuser has uid 0, usually named root. Its group is typically just root (gid=0). All other users are ordinary users, with no permission differences between them.
The sudo command temporarily elevates the current user’s privileges to run a process as root (i.e., the process and its children have root credentials).
$ sleep 1 & ps -o user,group,uid,gid -p $! & wait
[1] 171301
[2] 171302
USER GROUP UID GID
wokron wokron 1000 1000
$ sudo sleep 1 & ps -o user,group,uid,gid -p $! & wait
[1] 171399
[2] 171400
USER GROUP UID GID
root root 0 0
This is just the default usage of
sudo— it can also execute commands as other users. In that sense,sudocan be interpreted as “switch user do…”. The same applies to thesucommand below.
Only specific users can use sudo to run commands as root — after all, sudo only requires the current user’s password. A user who gains sudo access gains access to the entire OS. On Debian/Ubuntu systems, this requires adding the user to the sudo group.
Users with
sudoaccess have full system privileges;sudo’s purpose is simply to ensure “privileges are used only when needed.”
The su command runs a shell as root, but requires the root password.
If you don’t know the root password, you can use sudo to run a shell as root:
su
# sudo su
# sudo -i
Beyond the classic Unix permission model, Linux also implements a capabilities model. This model splits root’s privileges into individual “capabilities,” granting processes only the specific capabilities they need rather than full root access. This enables more fine-grained permission management. However, we won’t go into detail here.
File Permissions
As mentioned, apart from the superuser, all users are equal. But that doesn’t mean multiple users are useless on modern systems. A key role of multiple users is setting file access permissions.
Every file has owner, group, and permission information. When a process creates a file, the file’s owner and group are determined by the process’s credentials (user and primary group).
The group assignment actually has other possibilities, which we won’t detail here.
The following example shows how the group changes when switching primary groups:
$ id uid=1000(wokron) gid=1000(wokron) groups=1000(wokron) 1001(xxxxx) 1002(yyyyy) $ echo 1234 > test1.txt $ newgrp xxxxx $ echo 1234 > test2.txt $ ls -l test*.txt -rw-rw-r-- 1 wokron wokron 5 Feb 30 12:34 test1.txt -rw-rw-r-- 1 wokron xxxxx 5 Feb 30 12:34 test2.txt
File permissions consist of three parts, representing whether the “owner / group members / others” have “read / write / execute” permission. (This part is probably well-trodden ground…)
When a process tries to read/write/execute a file, the system determines which category it falls into:
- Owner: The process’s user equals the file’s owner
- Group member: The file’s group is among the process’s groups
- Others: Neither of the above
Based on the user type, the system then checks whether the process has the corresponding read/write/execute permission.
This is very useful. By properly configuring users and file permissions, we can prevent file access due to carelessness or malice.
For example, programs like MySQL need to maintain a set of files. They must ensure the consistency and security of these files, so they shouldn’t be accessible to others. At the same time, following the principle of least privilege, these programs don’t need to run as root. The proper approach is to create a dedicated user for each program. The program runs as that user, and the files it creates have different owners and groups from other users. Then simply set file permissions to prevent access by other users.
Of course, root can still access these files. And if you log in as that user or join that user’s group, you can access them too.
A real-world example is the docker group. The docker CLI communicates with dockerd through /run/docker.sock. Normally you need sudo to run docker commands because /run/docker.sock is owned by root:
$ ls -l /run/docker.sock
srw-rw---- 1 root docker 0 Feb 30 12:34 /run/docker.sock=
But notice that /run/docker.sock’s group is docker, and the group has the same read/write permissions as the owner. This means we just need to add the current user to the docker group to run docker commands without sudo. This is exactly the method described here.
Users in Containers
As we all know, containers are not virtual machines. This means resources inside a container necessarily exist somewhere on the host. The same goes for user resources.
Let’s run a container. Inside, we see our user is root. We write a file — its owner is also root:
wokron@host$ mkdir test_dir
wokron@host$ docker run -it --rm -v ./test_dir:/test_dir ubuntu:jammy bash
root@container$ whoami
root
root@container$ echo 1234 > /test_dir/a.txt
root@container$ ls -l /test_dir
-rw-r--r-- 1 root root 5 Feb 30 12:34 b.txt
root@container$ exit
This file is written to a volume. Looking at it from the host, the file is still owned by root:
wokron@host$ ls -l ./test_dir
-rw-r--r-- 1 root root 5 Feb 30 12:34 b.txt
This means one thing: root inside the container is root on the host. This is really dangerous, especially since containers tend to evoke a sense of isolation and security, making carelessness more likely.
If you don’t want to run as root, use the --user option when starting the container. This specifies the uid and gid for running commands. Then you’ll find the username inside the container becomes the startling I have no name!. But the uid and gid are still set correctly. This leads to an important conclusion: users and groups don’t matter — uid and gid do. As a process with root privileges, you can set any uid and gid, even if the corresponding user doesn’t exist.
wokron@host$ docker run -it --rm --user $(id -u):$(id -g) -v ./test_dir:/test_dir ubuntu:jammy bash
I have no name!@container$ id
uid=1000 gid=1000 groups=1000
Let’s write a file again. This time, instead of usernames and group names, uid and gid are displayed. This further supports the conclusion above:
I have no name!@container$ echo 1234 > /test_dir/a.txt
I have no name!@container$ ls -l /test_dir
-rw-r--r-- 1 1000 1000 5 Feb 30 12:34 a.txt
Exit the container and we can make a prediction: since our user’s uid and gid are 1000, the file created by the uid=1000 user inside the container should appear as created by the current user outside. And the result matches:
I have no name!@container$ exit
wokron@host$ ls -l ./test_dir
-rw-r--r-- 1 wokron wokron 5 Feb 30 12:34 a.txt
The “no username” problem (I have no name!) is easy to fix. Create a user with uid 1000 when building the image. Dockerfile even provides a USER directive to set the user for subsequent instructions and container execution:
FROM ubuntu:jammy
RUN useradd -u 1000 -m wokron
# USER wokron
wokron@host$ docker build -t test:v1 .
wokron@host$ docker run -it --rm --user wokron -v ./test_dir:/test_dir test:v1 bash
wokron@container$ whoami
wokron
Finally, there’s one more approach that makes the container’s root user behave as an ordinary user outside the container: using User Namespaces. This method provides greater safety when accessing the host filesystem from within the container. For more on Namespaces, see this article.