AWS IAM Deep Dive

AuthN and AuthZ overview

Introduction

Every action on AWS goes through an HTTPS API, and IAM (Identity and Access Management) sits in front of every single one of them.

Once you actually run things on AWS, you notice IAM is where you get stuck. “It says AccessDenied.” “The policy says Allow, why is it still rejected?” “I assumed the role but my credentials are still the old ones.” The patterns are predictable.

This article takes IAM apart in the order auth happens: authentication, then authorization, then operations.

  1. What IAM actually solves: authentication vs authorization
  2. Principals: who is making the call
  3. SigV4: how AWS verifies the caller is real
  4. The six policy types and what each one does
  5. The shape of a policy JSON
  6. Policy evaluation: why Deny beats Allow
  7. IAM Identity Center and short-lived credentials
  8. The do / don’t list

1. What IAM Solves: Authentication and Authorization

Start by separating authentication (AuthN) from authorization (AuthZ).

AuthN and AuthZ overview

  • Authentication (AuthN): pins down “who are you”. On AWS, the caller is whoever owns the credentials that produced this SigV4 signature.
  • Authorization (AuthZ): decides “can they do this”. AWS answers it by combining multiple policies and evaluating them together.

The rest of the article follows that same order: AuthN first, then AuthZ.

2. Principals: Who Is Calling AWS

In AWS, the entity making a call is called a Principal. Anything that can execute against a resource.

Type Credential Authenticated by When to use
Root User Account email + password AWS itself Almost never. Billing changes and a few special tasks only.
IAM User Access key (long-lived) or password IAM One per human or system. Long-lived keys are a leak risk.
IAM Role Short-lived credentials issued by STS AssumeRole The default. Roles over Users in modern setups.
Federated Identity External IdP token, swapped for AWS creds via STS Identity Center / SAML / OIDC The modern way humans log in.

An IAM Group is not a principal. It is just a container for attaching policies to a set of users. The group itself never authenticates and you cannot put it in the Principal field of a policy.

Don’t Use the Root User

Root has god-mode on the whole account. Only Root can change billing or close the account, and that is exactly why everything else should not be done as Root.

The AWS-recommended setup:

  1. After creating the account, set up MFA on Root immediately (basically required now).
  2. Never create an access key for Root.
  3. For day-to-day work, log in through IAM Identity Center or assume an IAM Role.
  4. Lock the Root credentials in a safe.

For MFA, TOTP (the 6-digit code from Google Authenticator, 1Password, etc.) works, but AWS recommends FIDO2 / WebAuthn passkeys or hardware keys like a YubiKey, because they resist phishing. AWS also recommends registering at least two MFA methods on Root so you don’t get locked out if one is lost.

Use IAM Roles Instead of IAM Users

The access keys on an IAM User (the ones that start with AKIA...) are long-lived. Once leaked, they stay valid until you rotate them. The stream of “committed an access key to GitHub, got abused within hours” stories isn’t slowing down.

IAM Roles solve this by handing out short-lived credentials via AssumeRole that expire after 1 hour by default (12 hours max).

Three pieces to know before reading the diagram:

  • STS (Security Token Service): the AWS service that issues temporary credentials. sts:AssumeRole and friends call into this.
  • Trust Policy: a policy attached to a Role that says “who is allowed to assume this role” (e.g. “only this specific IAM User”, “only this GitHub Actions repo”).
  • Temporary credentials: a triple of AccessKeyId, SecretAccessKey, and SessionToken, with an expiry.

AssumeRole sequence

3. SigV4: How AWS Verifies a Request Is Real

Once we know who the principal is, the next question is whether they actually signed the request. That is what SigV4 (Signature Version 4) does.

A typical REST API sends something like Authorization: Bearer <token> on every call. AWS does not. It never sends the Secret Key on the wire. It sends a signature computed from the Secret Key, every time.

The Big Picture

SigV4 signing sequence

What the 4 Steps Actually Do

SigV4 four steps

Three things to internalize:

  1. The Secret Key never leaves your machine. Only an HMAC derived from it goes on the wire.
  2. Clock skew will kill you. The timestamp is baked into the signature scope. If your laptop’s clock is off, you get InvalidSignature immediately. AWS tolerates roughly 15 minutes of skew.
  3. Signatures have a short validity window. To shrink the replay window, API calls only accept signatures from the last 15 minutes.

In practice the SDK and CLI do all of this for you, so you almost never compute it by hand. But when you see “calling another region from inside Lambda fails with a signature error” or “every S3 call from this Docker container returns 403”, the answer is almost always clock sync.

4. The Six Policy Types and Who Does What

Now for authorization. The reason AWS authorization feels complicated is that six different policy types are evaluated together.

Policy Attached to Role How often used
Identity Policy User / Group / Role “What can this principal do?” ★★★
Resource Policy S3 bucket, KMS Key, SNS, etc. “Who can touch this resource?” ★★★
SCP (Service Control Policy) OU / Account (Organizations) Per-account ceiling (a guardrail) ★★
Permissions Boundary User / Role Per-principal ceiling ★
Session Policy Args to AssumeRole / GetFederationToken Extra narrowing that only applies in a session ★
RCP (Resource Control Policy) Organizations Org-wide guardrail on resources Newer

The trick is to keep “ceiling” and “permission” separate in your head. Identity Policy is the “what you may do” list. SCP and Permissions Boundary are the “the most you will ever be allowed to do” list. No Allow anywhere means no access; a Deny in the ceiling kills it for sure.

5. The Shape of a Policy JSON

Every policy uses the same JSON structure, with five elements.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowReadOnlyMyBucket",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ],
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": ["203.0.113.0/24"]
        },
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        }
      }
    }
  ]
}

What to focus on:

  • Effect: only Allow or Deny. Default is Deny (if nothing says Allow, you get denied).
  • Action: s3:GetObject style, in <service prefix>:<API name> form. Wildcards (s3:*, s3:Get*) work.
  • Resource: the target, expressed as an ARN (Amazon Resource Name), e.g. arn:aws:s3:::my-bucket/*.
  • Principal: “who”. Not needed in an Identity Policy because the attachment target is implied. Required in a Resource Policy.
  • Condition: extra constraints like IP, MFA, tags, time, VPC endpoint, and so on.

Condition Is Where Real IAM Lives

Once you start writing IAM seriously, you live inside Condition. The ones I reach for most:

Key What it does Example
aws:SourceIp Source IP Allow only from the office IP block
aws:MultiFactorAuthPresent Did the caller use MFA? Require MFA
aws:PrincipalTag/Team Tag on the principal If Team = ml, allow only ml-bucket
s3:prefix Prefix being listed in S3 A user can only ls their own folder
aws:RequestedRegion Target region Deny anything outside ap-northeast-1
aws:SecureTransport HTTPS? Block plaintext HTTP access to S3

6. Policy Evaluation: Deny Beats Allow

AWS evaluates the six policy types in this order to reach a final decision.

Policy evaluation flow

(Note: this is the simplified, same-account version. Cross-account access has extra rules, e.g. the Resource Policy Allow is required, but the picture above is enough to grasp the principle.)

Three Iron Rules

  1. Default is Deny. If nothing says Allow, the answer is no.
  2. An explicit Deny overrides an explicit Allow. A single Deny anywhere ends the discussion.
  3. You need at least one Allow. No Allow = denied.

Without these three, you cannot debug “the policy says Allow but I still get AccessDenied”.

Common “Why Am I Denied?” Patterns

Symptom Cause
Identity Policy says Allow, still Deny An SCP is blocking that service at the org level
Can’t access your own bucket The bucket policy names a different Principal
Lost permissions after AssumeRole A Session Policy was passed as an argument
Only some actions get Deny’d A Permissions Boundary is narrowing via wildcards
Denied in only one region An SCP or IAM Policy has a Region condition

Why SCPs Are Powerful

An SCP (Service Control Policy) is an AWS Organizations feature that caps what an OU or account is allowed to do. Attach this SCP to a Sandbox OU and accounts under it cannot launch EC2 outside Tokyo.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOutsideTokyo",
      "Effect": "Deny",
      "Action": "ec2:RunInstances",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": "ap-northeast-1"
        }
      }
    }
  ]
}

Even if an IAM policy inside the account allows RunInstances in every region, the SCP Deny wins. That is what a guardrail buys you.

7. IAM Identity Center and Short-Lived Credentials

The era of humans logging in as IAM Users is over. The standard now is IAM Identity Center (formerly AWS SSO).

IAM Identity Center topology

The points:

  • The user logs in once via the company IdP (Google / Okta / Entra), and AssumeRole gives them short-lived credentials into every AWS account under it.
  • The contents of the role are defined as a Permission Set. Build a few of them (“ReadOnly is this, dev is that”) and assign per user × account.
  • No access keys are ever issued, so leak risk goes away structurally.
  • aws sso login covers the CLI side, dropping a temp token in ~/.aws/sso/cache.

For EC2 / Lambda / GitHub Actions

Non-human callers (apps, CI) should also not use long-lived keys. The current playbook:

Runtime Authentication
EC2 Instance Profile (attach a Role directly)
Lambda Execution Role
ECS / EKS Task Role / IRSA / Pod Identity
GitHub Actions OIDC, then AssumeRoleWithWebIdentity into an AWS Role
GitLab CI Same as above
External Kubernetes IAM Roles for Service Accounts (IRSA)

GitHub Actions OIDC is the strongest of the bunch: you can scope AssumeRole by repository or branch. Long-lived keys in GitHub Secrets become entirely unnecessary.

8. The Do / Don’t List

Tying the theory back to practice.

❌ Don’t

  • Create an access key for the Root user
  • Do day-to-day work as Root
  • Commit IAM User long-lived keys to GitHub
  • Hand out wildcards like s3:*
  • Use Principal: * in a Resource Policy without a Condition
  • Mix production and staging in the same account
  • Skip MFA entirely

✅ Do

  • Put MFA on Root and lock the credentials away
  • Move humans onto SSO via IAM Identity Center
  • Have GitHub Actions assume a Role via OIDC
  • Cap developer permissions with a Permissions Boundary
  • Set an org-wide guardrail with an SCP
  • Apply least privilege and audit with IAM Access Analyzer
  • Turn CloudTrail on in every account

Three of these are worth calling out:

  1. Treat long-lived access keys as something you will eliminate in the next few years. Roles + STS + Identity Center can cover the same ground.
  2. Be suspicious of *. Action *, Resource *, Principal *, no Condition: that combination is how incidents happen.
  3. CloudTrail and IAM Access Analyzer must be on. If you can’t reconstruct “who did what” after the fact, an incident becomes unsolvable.

9. Conclusion

  • IAM handles both authentication (who?) and authorization (allowed to?).
  • Principals come in four kinds: Root, User, Role, Federated. Groups are containers, not principals.
  • AuthN uses SigV4: the Secret Key never leaves you, clock drift is fatal, signatures live for 15 minutes.
  • There are six policy types (Identity / Resource / SCP / Permissions Boundary / Session / RCP) and they are all combined.
  • Evaluation rules: default Deny, explicit Deny beats explicit Allow, you need at least one Allow.
  • Modern best practice is Identity Center + Permission Sets + OIDC-based role assumption.
  • Long-lived keys are game over once leaked. Replace them with Role + STS.

References

I compressed timestamps before zstd and got up to 26.5% smaller log archives

This morning, I had fun compressing time.

But before you picture me as a theoretical physics genius or a crackpot in need of a straitjacket, let me explain.

I am currently working on Metarc, a metacompression tool.

By metacompression, I mean: applying smart transformations to certain structures before handing the data to a classical compressor like zstd.

Dates are a great playground for understanding this idea.

They look like text, but they actually contain a very dense structure that can be exploited.

And by exploiting this structure, Metarc can achieve compression gains of up to 26.5% compared to tar+zstd on some log datasets.

General-purpose compressors see bytes, not dates

Consider this date: 2026-05-11 10:36:19 That is 19 bytes of text.

For a general-purpose compressor, this is not fundamentally a date. It is a sequence of characters.

It can exploit pattern repetitions, but not the fact that this string represents a precise instant, with a known structure:

  • year
  • month
  • day
  • hour
  • minute
  • second

This difference in perspective changes everything.

Where a general-purpose compressor sees characters, Metarc can recognize a structure:

  • an instant
  • a format
  • sometimes a timezone
  • sometimes a precision in milliseconds or nanoseconds

That is the core idea.

Compress the structure before compressing the bytes.

Logs are the obvious test ground

To test this idea, log files were the obvious ground.

They contain many dates, in various formats, with a very concrete constraint: you must be able to restore exactly the original text.

Effectively compressing a date while keeping enough information to restore it in its original textual format imposes three constraints:

  • limit the complexity of detection and compression;
  • minimize the size of format metadata;
  • compress the most frequent formats first.

The last point is crucial.

The more formats I support, the more information I must store to distinguish them.

Up to 256 formats, one byte is enough.

Beyond that, two are needed.

The size/variety trade-off is permanent.

While looking for the most common formats on my laptop — RFC 3339, ISO 8601, macOS logs — I quickly understood that the standards hid a forest of variants:

  • ISO 8601 with nanoseconds UTC
  • ISO 8601 UTC
  • ISO 8601 with offset
  • RFC 3339 with nanoseconds and offset
  • and many more

To validate the principle, I did not need to be exhaustive.

A sufficiently widespread reduced subset should already allow compression on many encountered cases.

From there, the transformation applied by Metarc can be understood in three steps.

Step 1 — Reduce the instant

A dated moment is a point on a timeline.

If you choose an origin, in computing often January 1st 1970, it can be represented by a simple distance to that origin.

For example, this date: 2026-05-11 10:36:19
can be represented as a Unix timestamp.

Metarc encodes the instant in fixed numeric form on 8 bytes, with a granularity sufficient for the modern log formats targeted.

So we transform a textual string of 19 bytes into a fixed numeric block of 8 bytes.

Even if the date appears only once in the file.

This has two important consequences:

part of the gain comes from understanding the structure, not just from repetition;
unlike classical statistical compression, a unique date can already be reduced efficiently.

That is already useful.

But it is only half of the problem.

Step 2 — Restore the exact original format

The same instant can be written in dozens of ways:

  • 2026-05-11 10:36:19
  • Mon May 11 10:36:19 2026
  • 11/May/2026:10:36:19
  • 2026/05/11T10:36:19Z

Not to mention timezones, milliseconds or nanoseconds.

In reality, the number of possible formats is almost infinite.

To restore the date identically, Metarc must store both the timestamp and enough information about the original format.

The trade-off is therefore permanent:

  • detect fast;
  • encode fast;
  • store little;
  • remain more compact than the original date.

The first versions worked well.

But not always better than zstd alone.

That forced me to dig deeper.

Step 3 — Help zstd by ordering bytes by entropy

Once the timestamp and the format were encoded, a problem remained in the structure of the compressed format:

the order of the bytes.

A naive layout looked like this:

[0x00][fmt_byte][uint64 timestamp][int16 tz_min][uint8 subsec_digits]
__ Low ___/    ___ HIGH ___/    ___________ Low ________________/

The timestamp changes a lot.

The format, timezone and precision usually change much less.

So by reorganizing the format to group low-entropy data at the beginning, we create a longer repetitive sequence that zstd can compress better:

[0x00][fmt_byte][int16 tz_min][uint8 subsec_digits][uint64 timestamp]
____________________ Low ________________________/ ___ HIGH ___/

Metacompression acts here in two ways:

it compresses the date itself;
it optimizes the byte ordering for the downstream compressor.

The knowledge of the structure — what changes a lot and what changes little — enables this double gain.

That is the interesting part.

Not just replacing text with binary.

Preparing the data so that zstd sees a better byte stream.

Results on Loghub

For my tests, I used Loghub, a corpus containing real log files produced by standard software.

Metarc archives files by applying metacompression transformations and then compresses with zstd by default.

That is why I compare it with:

tar --zstd -cvf xxx.tar.zstd xxx

and:

marc archive xxx.marc xxx

On the Loghub corpus, with the formats currently recognized by log-date-subst/v2, Metarc gains up to 26.5% compared to tar+zstd.

On unrecognized formats, the gain is logically 0%.

Dataset Directory size tar+zstd size Metarc size Metacompression benefit
Mac 844K 136K 100K 26.5%
OpenStack 1.3M 136K 104K 23.5%
HDFS 708K 136K 112K 17.6%
BGL 776K 136K 116K 14.7%
Spark 500K 36K 32K 11.1%
OpenSSH 580K 40K 36K 10.0%
HealthApp 472K 44K 40K 9.1%
Android 736K 52K 48K 7.7%
Zookeeper 648K 52K 48K 7.7%
Apache 432K 28K 28K 0.0%
Hadoop 920K 44K 44K 0.0%
HPC 372K 56K 56K 0.0%
Linux 548K 36K 36K 0.0%
Proxifier 592K 52K 52K 0.0%
Thunderbird 768K 64K 64K 0.0%
Windows 684K 32K 32K 0.0%

For recognized formats, the size gain brought by metacompression is **14% on average.

The gain depends directly on the proportion of dates recognized in the corpus.

When Metarc detects a format, it reduces the temporal information and reorganizes it for zstd.

When it detects none, the file simply follows the standard compression path.

You can check the detail of the gain per archive with the --explain flag:

time marc archive --explain mac.marc Mac

Example output:

--- Plan Summary ---
Total entries:      4
Transforms applied: 1
Estimated gain:     104.0 KB

Breakdown by transform:
  log-date-subst/v2        1 applied      0 skipped  ~104.0 KB saved
  raw                      0 applied      3 skipped  ~0 B saved

What still needs improvement

Implementing more formats without degrading performance remains an obvious axis for improvement.

Adding a format can sometimes come down to encoding a simple variant, such as the date separator: / or -.

But more often, it requires detecting a new pattern in a new set of log files.

The real work is therefore to broaden coverage without blowing up the detection cost.

There are also still many optimizations to implement.

For example, some compressed storage formats could be specialized.

No point keeping timestamps in uint64 format for formats that do not need nanoseconds.

The current implementation validates the principle.

It does not exhaust the idea.

The real point: metacompression

The real result of this exploration is not, for me, the average 14% compression gain.

The interest of date compression is that it illustrates metacompression in its most complete form:

an upstream compression that adds to and optimizes downstream byte stream compression.

That was Metarc’s bet:

compress the structure before compressing the bytes.

The next step will not play out on a hand-picked example, but on real data:

  • code repositories
  • logs
  • technical archives
  • heterogeneous corpora

That is how an intuition becomes a tool.

If you have real logs with timestamp formats that Metarc does not handle yet, I would be very interested in testing them.

Try Metarc, compare it to tar+zstd, and share your results:

And if you like the concept of metacompression and want to give the project some visibility, a GitHub star is always appreciated.

How I Stopped My AI Coding Assistant from Hallucinating (and Saved My Token Budget)

Every developer using tools like Claude Engineer, ChatGPT, or Lovable eventually hits the exact same wall.

You start a new project, and everything feels like magic. The AI understands your vision, writes clean components, and you’re moving at warp speed. Then week two hits. The codebase gets larger, you add a few nested directories, and suddenly, the AI goes sideways. It forgets how your routing works. It tries to reinstall dependencies you already settled days ago. Worst of all, it accidentally overwrites a feature you already fixed.

If you like switching between models—say, bouncing from Claude 3.5 Sonnet to Gemini 1.5 Pro depending on usage limits—onboarding the new model becomes an absolute nightmare. You waste hundreds of tokens just trying to explain, “No, don’t use that database library, use this one.”

To solve this, I built a lightweight framework in my root directory called the .ai_context protocol. It keeps the AI grounded, enforces strict guardrails, and drops token bills significantly.

Here is exactly how it works and why you should steal it for your current projects.

The Core Fix: A Router for AI Context
Most people let their AI tools blindly scan their whole workspace or pass massive chunks of code back and forth in the prompt. This burns through your token limit and clutters the LLM’s working memory with noise it doesn’t need for simple tasks.

The .ai_context protocol changes that by introducing five simple Markdown files at your project root:

Plaintext
your-project-root/
├── .ai_context/
│ ├── README.md <– The “Router” & Rules
│ ├── completed_features.md <– Read-only historical log
│ ├── future_roadmap.md <– The strict backlog
│ ├── architecture_map.md <– File tree & structural flow
│ └── secrets_manifest.md <– Tracking env variables safely
The real magic here is the README.md. It acts as a traffic controller. Instead of the AI loading all files simultaneously, the README explicitly dictates when the agent is allowed to open the other files.

If you are just asking for a small CSS bug fix, the AI reads the README, realizes it doesn’t need to touch the roadmap or secrets log, and stops right there. Huge token savings.

Why This Actually Works (From a Human Perspective)

  1. Zero-Friction Model Handoffs
    When you switch to a brand new AI agent, you don’t need to write a massive explanation. You simply prompt it: “Read the .ai_context/README.md and tell me what our next task is.” The new model is instantly on track without guessing.

  2. Guardrails Against Hallucination
    Because the AI maintains a read-only ledger of what is already built (completed_features.md), it stops inventing weird, duplicate utility functions. It knows exactly what tools are available in the codebase.

  3. Bulletproof Security
    We’ve all seen AI agents accidentally hardcode a secret token or an API key right into a client-side file. The secrets_manifest.md keeps a strict map of environment variable locations without ever exposing the actual values. It forces the AI to check your .gitignore configuration before writing backend logic.

How to Set It Up Instantly
If you want to try this out, I made a single-prompt setup script. You just copy the prompt, drop it into your workspace AI agent, and it generates the entire folder structure and populates your current repository layout automatically.

full promt link

If you are building legacy projects, maintaining side hustles, or fully embracing the AI-assisted development loop, this is the missing manual. It takes two minutes to set up, but it completely changes how reliably your AI handles your code.

How are you keeping your workspace agents from drifting out of context? Drop a comment below—I’d love to see how other people are organizing this.

Everything Is an Account on Solana: A Complete Guide for Web2 Developers

The One Idea That Changes Everything

When I started learning Solana after years of Web2 development, one sentence kept appearing everywhere: “Everything is an account.” I nodded along without really understanding it. It sounded like a slogan.

It isn’t. This is the most important architectural decision Solana made, and once you understand it, everything else about how Solana works starts to make sense.

This post explains Solana’s account model from scratch, written for developers who know databases, filesystems, and APIs, but are new to how Solana organizes on-chain state.

What Is an Account?

In Solana, an account is a record stored on the blockchain. Think of it like a row in a global database, except this database is replicated across thousands of validators worldwide, nobody owns it, and every write to it costs a small fee.

Every account has the same structure, regardless of what it’s used for:

  • Lamports: the account’s SOL balance, denominated in the smallest unit (1 SOL = 1,000,000,000 lamports)
  • owner the program that has the authority to modify this account’s data
  • data: an arbitrary byte array; this is where state is stored
  • executable: a boolean flag; if true, this account contains a program

That’s it. Four fields. Wallets, programs, token balances, NFT metadata, swap pool state they’re all just accounts with these four fields filled in differently.

The Four Fields, Explained

lamports

Every account must hold enough SOL to stay “rent-exempt” (more on that below). The lamports field tracks this balance. For a wallet account, this is your spendable SOL. For a program account, it’s the deposit that keeps the account alive.

When you send SOL to someone, you’re reducing the lamports in your account and increasing the lamports in theirs. Simple. But this same mechanism is also how you pay for storing data on-chain the more data an account holds, the more lamports it needs to stay rent-exempt.

owner

This is the field Web2 developers find most surprising. Every account on Solana is “owned” by a program. The owner field contains the address of the program that has permission to modify that account’s data field.

Your wallet account? Owned by the System Program. Your USDC token balance? Stored in a token account owned by the Token Program. A DEX’s liquidity pool? An account owned by the DEX’s program.

Here’s the key rule: only the owner program can modify an account’s data. Anyone can read any account. But writes are gated by ownership. This is Solana’s access control model, and it’s enforced at the runtime level — no exceptions.

data

This is the payload. For wallet accounts, the data is empty; wallets only need to track a SOL balance, which lives in lamports. For program-owned accounts, data contains whatever state the program needs to store, serialized as bytes.

A token account’s data contains: the mint it belongs to, the owner’s wallet address, and the token balance. An NFT’s metadata account contains: the name, symbol, URI, and royalty information. A DEX pool’s account contains: the reserve amounts, fee configuration, and liquidity parameters.

The program defines the structure. The account stores the bytes. Solana’s runtime doesn’t care what’s in the data field that’s between the program and whoever wrote it.

executable

If this boolean is true, the account’s data field contains compiled program bytecode rather than state data. The Solana runtime will execute this bytecode when the program is invoked.

This is the elegance of the account model: programs and data use the exact same storage primitive. A program is just an account where executable = true. There’s no separate “smart contract” type, just accounts.

Why Programs and Data Live in Separate Accounts

Coming from Ethereum, this is the biggest conceptual shift. On Ethereum, a smart contract bundles its logic and its state together. The contract code and the contract’s storage live at the same address.

Solana separates them completely. A program account contains only executable bytecode it holds no mutable state. All state is stored in separate data accounts that the program owns.

Why? Two reasons that matter a lot in practice:

Programs are immutable by default. Because a program account only contains code and no state, upgrading a program doesn’t affect the data accounts it manages. You can deploy a new version of a program without migrating user data. The data accounts keep pointing to the program’s address, and the new code reads and writes them in the new way.

Parallelism. Solana can execute transactions in parallel when they don’t touch the same accounts. By separating program logic from program state, Solana’s scheduler can identify which transactions are truly independent and run them simultaneously. This is a significant part of how Solana achieves high throughput.

How Ownership Controls Who Can Change Data

Imagine a user’s token account, let’s call it Account A. It’s owned by the Token Program. It stores the token balance.

If you submit a transaction that tries to directly modify Account A’s data without going through the Token Program, the Solana runtime will reject it. Full stop. The runtime checks the owner field and enforces that only the Token Program can write to this account.

So how does a user move their tokens? They submit a transaction that calls the Token Program with the right instruction. The Token Program verifies the user’s signature, checks that they’re the authority for Account A, and then as the owner modifies Account A’s data to reflect the new balance.

The user doesn’t write to their account directly. They ask the program that owns their account to write on their behalf, after proving they have the right to make that request.

This is a fundamentally different mental model from Web2, where your server code writes directly to your database. On Solana, the “database row” specifies which “server” is allowed to modify it, and that rule is enforced by the network itself.

What Is Rent Exemption?

Storing data on a blockchain costs resources validators need to keep every account in memory to process transactions quickly. Solana handles this with a concept called rent.

Originally, accounts paid a small ongoing fee (rent) to stay alive. Accounts that ran out of SOL would be deleted. This created complexity you’d have to top up accounts periodically or lose your data.

The modern approach is rent exemption: if an account holds enough SOL (roughly 0.002 SOL per kilobyte of data, though the exact amount varies), it’s considered rent-exempt and will live forever. It never pays ongoing rent.

This is why creating a new token account costs a small amount of SOL you’re depositing the rent-exempt minimum upfront. When you close an account, that SOL is returned to you.

As a developer, the practical implication is that when you create any new on-chain account, you need to fund it with enough SOL to be rent-exempt. Your users will pay this deposit when they interact with your program for the first time.

Putting It Together

Here’s the mental model that clicked for me:

Solana is a global key-value store. Every account is a value (with four fields) stored at a key (its address). Programs are values where executable = true. Data are values where a program’s address is set as the owner.

When you interact with Solana, you’re either

  1. Reading values from this store (free, anyone can do it)
  2. Writing values by submitting transactions (costs fees, gated by ownership rules)

Every concept in Solana wallets, tokens, NFTs, DeFi protocols is built on top of this single primitive. Once you have this model in your head, the rest of Solana starts to feel like a natural consequence of a few simple rules applied consistently.

That’s what “everything is an account” actually means.

From 80 Days Around the World to 80 Days in the Dark

From 80 Days Around the World to 80 Days in the Dark

“Eighty days,” Jules Verne wrote in 1872, was how long it took to circle the planet. In Iran today, it is how long a developer in Tehran has been unable to reach a single server outside it.

Part 1: The morning everything stopped

You wake up. You pour tea. You open your laptop and run git pull.

It hangs.

You ping 8.8.8.8 — nothing. You curl github.com — DNS failure. You restart the router, the modem, the laptop. You call your ISP. The person on the phone has had this call all morning, and the morning before that, and the morning before that:

“It’s the whole country. We don’t know when it comes back. We never do.”

That was day one. Today is day 80. Tomorrow will be day 81. Nobody knows what day it ends on.

This is the story of friends, ex-colleagues, and a whole generation of developers still in Iran — people who, right now, have been almost completely cut off from the global internet for 80 days and counting.

If you’re a developer reading this from anywhere else, imagine — for the next few minutes — what it would mean if every package manager you depend on stopped working tomorrow. npm, pip, go mod, cargo, gem, mvn, whichever you use.

Not for a day. Not for a week. For three months. With no end.

Part 2: The dead screen

The blackout breaks the toolchain one error at a time — until you realize the whole thing you depend on lives somewhere you can no longer reach.

Day one in an Iranian developer’s terminal looks like this:

Tool What happens
git push origin main Could not resolve host: github.com
npm install Stalls on the first fetch from registry.npmjs.org
pip install Same story with PyPI
go mod download dial tcp: lookup proxy.golang.org: no such host
cargo build crates.io unreachable
mvn install / gradle build Maven Central times out
docker pull TLS handshake timeout
apt update Mirrors unreachable
Stack Overflow A few pages from browser cache. None of the answers.
ChatGPT, Claude, Gemini, Copilot, Cursor All blocked. Hard.
MDN, React docs, anything on Cloudflare Half the web’s docs, gone
Slack, Discord, Telegram Gone
Your GitHub Actions They run somewhere. You can’t see the logs.

What the table doesn’t show is the second layer.

Your linter quietly tries to fetch a rule update. Your editor tries to refresh its extensions. Your CI pipeline, running in some server farm in Frankfurt, sits waiting for commits that will never arrive.

Even the tools you thought were “local” turn out to be thin shells over a server somewhere else. Everything you do, all day, is a request to somewhere else.

When somewhere else is unreachable, you find out how thin your local computer actually is.

Part 3: And it’s not just developers

The developer story is one room in a much bigger house. The whole house is dark.

Until 80 days ago, Iran was a country that lived on the internet.

  • Telegram was where daily life happened.
  • Instagram was where a generation of Iranian women built their own businesses, where photographers and artists earned a living, where the shops without a storefront lived.
  • WhatsApp was the lifeline to family abroad — to the cousins in Toronto, the children in Berlin, the brother in Dubai.

All of it, gone overnight.

For hundreds of thousands of Iranians, those apps weren’t social media — they were the whole business.

The woman in Karaj selling homemade shirini (Persian sweets) through DMs. The seamstress taking orders from her bedroom. The mechanic in South Tehran posting before-and-after photos of Pride repairs. The Konkur tutor running online classes for students preparing for the university entrance exam. The hairdresser, the photographer, the calligrapher, the spice seller, the young woman embroidering cushions from her bedroom.

None of these people had a website. None of them needed one. They had an Instagram page for the shop, a Telegram channel for orders, and a bank card number — customers transferred the money, took a screenshot of the receipt, and sent it back.

Now the shop has no door.

This isn’t a story about things being a little less convenient. It’s a story about people who can’t pay this month’s rent or this year’s rahn — Iran’s housing deposit, often a year’s salary handed to the landlord all at once.

It’s a story about families buying less meat, less fruit, less of everything. About a business someone built over five years disappearing in one morning, with no way to get it back.

The blackout didn’t just cut a country off from the global internet. It cut hundreds of thousands of families off from the way they buy bread.

Developers aren’t the only victims of this blackout. We’re just the ones who can describe the damage in code.

Part 4: The workarounds you’d imagine? None of them exist.

If you live in a country where the internet works, you’ll think of one word right away: VPN.

“Everyone in Iran uses VPNs, right?”

Not anymore.

For years, Iranian developers fought a long battle over tunneling protocols — V2Ray, Xray, Reality, Hysteria, Trojan, Shadowsocks, WireGuard-over-TLS — every trick engineers could think of to make their traffic look harmless to the censor.

That battle ended 80 days ago. The censor stopped playing — and hasn’t started again.

Every public protocol, every paid provider, every config — dead. No Psiphon. No Lantern. No Cloudflare WARP. No clever setting hidden on a forum somewhere, because the forum doesn’t load either.

When the tunnel closes, everything else breaks:

  • No GitHub. No GitLab. No Bitbucket.
  • No npm. No PyPI. No crates.io. No proxy.golang.org. No Maven Central. No RubyGems. No Packagist. No Docker Hub.
  • No ChatGPT, Claude, Cursor, Copilot, Gemini.
  • No Stack Overflow. No MDN. No React docs. No AWS console.
  • No Slack. No Discord. No Telegram. No WhatsApp.
  • No Zoom. No Google Meet.
  • Mirrors that exist in name only. Iran has local mirrors for Linux, npm, PyPI, Docker Hub, the Go proxy, Maven — universities and a few volunteers set them up years ago. But they’re months out of date, throttled to a crawl, rate-limited, and half-broken. The badge on the homepage works. The package never finishes downloading.
  • No local AI model you can run, unless you happened to download the weights to your hard drive before the blackout started.

What’s left? Only what was already on a hard drive on day one, and the physical movement of data — and, if you can pay for it, a black market I’ll explain in a moment.

  • Pre-blackout stashes. node_modules folders, Docker images, .deb packages, model weights, doc snapshots — anything saved before the blackout is now treasure.
  • Sneakernet. A USB drive passed between coworkers. A friend who drives across the city to hand-deliver an updated company tarball. Anything from abroad has to come overland — across the Turkish, Iraqi, or Armenian border — and arrives weeks or months late.
  • The friend abroad. Someone outside Iran downloads what’s needed and waits weeks or months for it to physically reach the developer who asked.
  • Stop. A lot of developers just stopped. They can’t ship, can’t learn, can’t get paid. They’re reading old books and waiting.

Part 5: Some animals are more equal than others

“All animals are equal, but some animals are more equal than others.”
— George Orwell, Animal Farm

Not everyone is equally cut off.

In any blackout, some people get exceptions. Government offices need access for reasons they don’t have to explain. Some businesses, some ministries, some people with the right connections get what’s quietly called “Internet Pro” — a special, government-approved level of internet that goes past the block.

The official excuse changes depending on who’s asking. The real reason is connection — who you know, what you can pay.

The moment that special access exists, a market exists.

If you have Internet Pro and your neighbor doesn’t, your apartment becomes the internet. You set up a hotspot, a SOCKS proxy, a tunnel — whatever the buyer needs — and you charge by the gigabyte.

The price runs anywhere from 10x to 100x what internet used to cost when the country was online.

Here’s what that means for a developer:

  • A docker pull of one base image: 500 MB. At 50x the old price, that’s a day’s worth of food — for one pull.
  • A fresh dependency install — npm install, pip install, go mod download, cargo build, mvn install, whichever you use: 100-500 MB. Multiply by every time your dependencies break.
  • Downloading the weights for one local AI model that might let you keep working: 8 GB. That’s a week of groceries.
  • A git clone of your company’s monorepo: 2 GB. Your employer doesn’t pay you back. Your employer also can’t see your work.

The blackout is not the same for everyone. Some people are hit hard. Some barely feel it.

At the top: senior engineers with money saved and the right contacts. They can pay for a black-market tunnel and keep working at half speed.

In the middle: developers counting every megabyte, choosing which one task this week is worth the cost.

At the bottom: students, juniors, freelancers. For them, one hour online costs more than they used to earn in a day. They’re not just disconnected. They’re priced out of being a developer.

A blackout creates a black market. A black market creates a class system. And in the dev community, that class system will last longer than the blackout itself.

The book this all comes from

Orwell wrote Animal Farm about a revolution that promised equality and gave the opposite. He thought he was writing about Soviet communism. He was.

But he was also writing — without knowing it — about every revolution that came after and made the same promise and broke it the same way. Including the Islamic Republic that has run Iran since 1979.

The hammer and sickle becomes a flag of God. The Party becomes the clergy. The commissar becomes the Revolutionary Guard.

The flag, the slogans, the scripture — all of these change. The farm stays the same.

The blackout was presented as one rule for everyone — the country is offline, no exceptions. Eighty days later, the exceptions are the story.

Some animals have wifi. The rest watch the pigs walk on two legs and pretend not to see.

Verne and Orwell, together

The two writers see opposite halves of the same picture.

Verne saw the world getting smaller. Steamships and railways were shrinking the planet to eighty days of travel, and he was right — the world did get smaller.

Orwell, decades later, saw what Verne missed:

A world that is “small” for some people is, by the same act, a fence around others.

For every railway that connects two cities, there is a border the railway does not cross. For every steamship, there is a port someone is not allowed to leave. The smallness Verne celebrated was never for everyone — it was a privilege handed out by whoever owned the railways and the steamships and, today, the routers and the fiber.

Iran is on the wrong side of that line.

The same eighty days that once meant the world is reachable now mean the opposite. Eighty days of proof that privileges can be revoked — and that when they are, the country left behind disappears from the map of the connected world overnight.

Part 6: The things you don’t think about until they’re gone

The technical problems are solvable in theory. What you can’t copy offline is the rest of being a developer in 2026.

  • You can’t get paid. Stripe, Wise, PayPal were never available to Iranians — sanctions blocked them years ago. Crypto, especially USDT through P2P channels, was the only workaround. The blackout killed that too. Now the only payment route is a friend abroad receiving the money for you and waiting weeks to physically hand it over.
  • You can’t ship. Your AWS console doesn’t load. Your Vercel deploys wait forever. Your CI runs somewhere, without you.
  • You can’t apply for remote jobs. The application platforms don’t open. And even if they did, sanctions blocked the payment side to Iranians years before the blackout closed the application side. The trap closes from both ends.
  • You can’t update your portfolio. Your own website works in Berlin and doesn’t exist in Tehran.
  • You can’t learn. Every tutorial, every course, every new framework’s docs — gone.

A whole generation of brilliant engineers is, professionally, going dark. They didn’t stop working. The world stopped seeing them work.

Part 7: What you can actually do

I didn’t write this to make you sad. There are real things the global dev community can do, and most people don’t know what they are.

1. Don’t geoblock by default — and check your assumptions about who’s on the other side

Half the SaaS tools on the planet block Iranian IPs as an easy way to follow the law.

The unspoken assumption behind those blocks is that every Iranian IP belongs to the regime — that every user is Revolutionary Guard or a regime official.

They are not.

The actual people behind those IPs are developers, students, journalists, women, dissidents — mostly the opposite of the regime your sanctions are aimed at. They are the ones being beaten in the streets, the ones being arrested, the ones whose internet was just cut off.

The Revolutionary Guard has other ways through your blocks. The only people you stop are the ones on your side.

When access comes back — and it will, in waves — your service being open by default could be the difference between an Iranian dev sending their first PR or giving up. Check if your geoblock is actually required by law, or just a default someone never turned off.

2. Hire Iranian developers

Iranian engineers are some of the most resourceful in the world. Most of them aren’t trying to leave Iran. They’re trying to keep working from where they are.

Hire them remotely. Hire them per project. Pay in USDT through person-to-person channels — the only payment route that still reaches Iran. A modest USD contract is life-changing in the Iranian economy right now.

3. Sponsor open-source maintainers from Iran

GitHub Sponsors, Open Collective, direct USDT — whatever channel reaches them.

$50 a month right now can be the difference between someone paying for a black-market tunnel to keep contributing, or going silent.

4. Build better internal mirrors inside Iran — for the next blackout

This one is for the Iranians who still have internet access right now: universities, ISPs, sysadmins, anyone with the capacity to run a mirror.

The mirrors inside Iran today are broken. Out of date, throttled, half-broken — the badge on the homepage works, the package never downloads. That is the situation every Iranian developer is paying for right now.

The next blackout will come. When it does, the only mirrors that matter will be the ones already inside the country. The external mirror that the rest of the world maintains is useless to a developer who cannot reach the rest of the world.

Build properly maintained internal mirrors of npm, PyPI, crates.io, the Go proxy, Maven Central, Debian. Keep them current. Give them the bandwidth and capacity to actually serve real traffic. Run them quietly — at universities, at companies, on personal servers. Snapshot them regularly so the data survives even if any single host goes down.

The infrastructure that exists when the next blackout begins will be the infrastructure Iran has during it. The work to build it is now — while the internet still works.

Part 8: The part that matters

Right now, as I publish this:

  • There is a developer in Tehran who hasn’t run git push in 80 days. Tomorrow he won’t either. The day after, probably not. No one has told him when he can.
  • There is a junior dev in Shiraz who can’t finish the React tutorial she started in February.
  • There is a CTO in Isfahan watching her seed round disappear because no investor call will stay connected.
  • There is a freelancer in Tabriz who hasn’t been paid in three months because the platform that owes him money won’t even load.
  • There is a student in Mashhad who calculated last night that one hour on a black-market tunnel costs more than his father earns in a day, and quietly closed his laptop.

They are still building. Slowly, quietly, with USB drives and patience and whatever internet they can afford.

And the hardest part — harder than the broken git push, harder than counting every megabyte, harder than the missed paychecks — is the silence from the rest of the world.

Eighty days of a country going dark, and the global news scrolled past it. No emergency meeting. No tech CEO statement.

If this were happening in a country the global press had decided was important, it would be on every front page.

It isn’t.

The silent message to every Iranian — developer, grandmother, child — is that their disconnection is not an event.

They are not an event. They do not matter enough to be noticed.

The least the rest of us can do is prove that wrong.

One last thing — about the official story

I am not a political person. I am an engineer. I write about code. I write about systems. I write about real engineering work. This is not what my keyboard is usually for.

But I have to say this once, clearly.

The Iranian government has an official reason for the blackout. But before I get to the reason, let me name where the decision actually comes from — because outside Iran, this is misunderstood.

The blackout was not ordered by “the government” in the way Western readers picture government. Iran’s elected officials — the president, the parliament, the cabinet — do not hold real power. They are the shadow show. The actual decisions come from the Supreme Leader and the Islamic Revolutionary Guard Corps (IRGC). Cutting a country off from the internet is their order, not the cabinet’s.

They say it is for national security. They say it is because of the war. They say it is temporary.

They are lying. Every one of those is a lie.

A country cannot fight back if it cannot see what is being done to it. It cannot ask for help if it cannot reach the world. It cannot stand up for itself if its people cannot reach each other.

That is the real reason. Everything else is a lie the Supreme Leader and the IRGC tell to keep the world from looking too closely — and to keep their own people from looking at each other.

Verne’s hero needed eighty days to circle the planet. An Iranian developer has spent the same eighty days unable to leave the country at all — not on a plane, not on the internet, in no direction at all.

For most of the world, the planet keeps getting smaller. For Iran, it has gotten very large again. And no one is rushing to shrink it back.

If you’re anyone else reading this, please share it. The loudest thing we can do is refuse to let this stay silent.

Hukuk, bilim ve kültürel miras aynı çatıda buluştu

Anayasa Mahkemesi Başkanı Kadir Özkaya ve heyet, Erzurum’da Atatürk Üniversitesi’ni ziyaret ederek Rektör Prof. Dr. Ahmet Hacımüftüoğlu ile yükseköğretim, hukuk ve bilimsel üretim konularında görüş alışverişinde bulundu. Ziyarette yerli ilaç üretimi ve üniversite-sanayi iş birlikleri ele alındı.

Build Your Own Container Runtime in Go: From Zero to a Running Isolated Process

I built gocount as a way to actually understand what Docker does under the hood. By the end of this post you’ll have a working container runtime that boots an Alpine Linux shell in its own filesystem, PID tree, hostname, and network, with enforced memory and CPU limits, using nothing but Go and Linux kernel features.

Why Build a Container Runtime?

Containers feel like magic until you look at what’s actually happening. When docker run executes, the kernel doesn’t spin up a virtual machine. It just creates a process with a restricted view of the system, shaped by five or six kernel primitives that have been in Linux since 2008.

Every production container runtime (Docker, Podman, containerd) is ultimately a thin orchestration layer on top of those primitives. Building one yourself is the fastest way to understand:

  • What a container actually is (spoiler: it’s a process)
  • How pivot_root replaces the filesystem you see
  • How cgroups enforce the memory limit that OOM-kills your app
  • How the veth pair wires the container to the outside network

Prerequisites

Requirement Why
Linux (kernel 5.10+ recommended) All the kernel features used here are Linux-specific
Go 1.23+ Module system + syscall / golang.org/x/sys
iproute2 (ip command) Creating and managing network interfaces
iptables NAT masquerading for container internet access
Root access Namespaces, cgroup creation, and pivot_root require it
nsenter Moving a network interface into a namespace

Check your kernel supports cgroup v2 (required for memory + CPU limits):

cat /sys/fs/cgroup/cgroup.controllers
# Should include: cpuset cpu io memory hugetlb pids rdma

Project Layout

gocount/
├── main.go
├── go.mod
├── Makefile
├── cmd/
│   ├── root.go       # cobra root command
│   ├── run.go        # run & start subcommands + child setup
│   ├── ps.go         # list containers
│   ├── stop.go       # stop & rm commands
│   └── inspect.go    # inspect a container
└── internal/
    ├── container/
    │   ├── container.go   # Container struct, in-memory map, JSON persistence
    │   ├── mount.go       # pivot_root, /proc /sys /dev mounts, DNS
    │   └── utils.go       # EnsureContainerDir
    ├── cgroups/
    │   └── cgroups.go     # cgroup v2 create / limits / delete
    ├── rootfs/
    │   └── manager.go     # download & extract Alpine Linux rootfs
    └── network/
        └── network.go     # veth pair, IP forwarding, NAT, in-container setup

Initialise it:

mkdir gocount && cd gocount
go mod init gocount
go get github.com/spf13/[email protected]
go get golang.org/x/[email protected]

Concept 1: Linux Namespaces

A namespace wraps a global resource and gives a process the illusion that it has its own isolated instance of that resource. gocount uses four:

Flag What it isolates
CLONE_NEWPID PID space — the container’s first process is PID 1
CLONE_NEWUTS Hostname and domain name
CLONE_NEWNS Mount tree — filesystem changes are invisible to the host
CLONE_NEWNET Network stack — the container gets its own lo, no host interfaces

When you combine all four, the process is isolated enough to look and feel like a separate machine, even though it’s sharing the same kernel as the host.

Concept 2: cgroup v2

cgroup v2 is the kernel’s resource accounting and limiting subsystem. It’s a virtual filesystem mounted at /sys/fs/cgroup. You control a process by:

  1. Creating a subdirectory: mkdir /sys/fs/cgroup/gocount/<id>
  2. Writing the limit: echo 104857600 > /sys/fs/cgroup/gocount/<id>/memory.max
  3. Writing the PID: echo <pid> > /sys/fs/cgroup/gocount/<id>/cgroup.procs

Once the PID is in the cgroup, the kernel enforces the limit. Exceed it and the OOM killer fires.

Concept 3: pivot_root

chroot changes where a process looks for /, but pivot_root is stronger. It actually swaps the root mount point and lets you unmount the old one entirely, so the process can’t escape back to the host filesystem.

The steps:

  1. Bind-mount the new rootfs onto itself (required by the kernel)
  2. Create a /.pivot_root directory inside the new rootfs
  3. Call pivot_root(newroot, newroot/.pivot_root)
  4. chdir("/") to land inside the new root
  5. Unmount /.pivot_root with MNT_DETACH
  6. Remove /.pivot_root

After step 6, the host filesystem is completely gone from the process’s perspective.

Concept 4: veth pairs

A veth pair is a virtual Ethernet cable with two ends. When you move one end into the container’s network namespace, you get a private point-to-point link between host and container. gocount then:

  • Assigns 10.0.0.1/24 to the host end
  • Assigns 10.0.0.2/24 to the container end
  • Adds a default route through 10.0.0.1
  • Configures iptables NAT (masquerade) so the container can reach the internet

Step 1: Entry Point and CLI

main.go
cmd/root.go

main.go is two lines:

// main.go
package main

import "gocount/cmd"

func main() {
    cmd.Execute()
}

cmd/root.go registers the root cobra command:

// cmd/root.go
package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "gocount",
    Short: "gocount is a minimal container runtime",
    Long:  `Run Linux processes in isolated namespaces, like a tiny Docker.`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

Step 2: The Container Struct and Persistence

Every running container is represented by this struct and stored in two places: an in-memory map for fast lookup, and a JSON file under /tmp/gocount/<id>.json so it survives process restarts.

// internal/container/container.go
package container

import (
    "encoding/json"
    "fmt"
    "math/rand"
    "os"
    "strings"
)

type Container struct {
    ID      string
    Pid     int
    Command []string
    Status  string
    RootFs  string
    Cgroup  string
}

var Containers = map[string]*Container{}

func GenerateID() string {
    letters := "abcdefghijklmnopqrstuvwxyz0123456789"
    id := ""
    for i := 0; i < 8; i++ {
        id += string(letters[rand.Intn(len(letters))])
    }
    return id
}

func SaveContainer(c *Container) error {
    data, _ := json.Marshal(c)
    path := fmt.Sprintf("/tmp/gocount/%s.json", c.ID)
    return os.WriteFile(path, data, 0644)
}

func LoadContainers() ([]*Container, error) {
    var containers []*Container
    files, _ := os.ReadDir("/tmp/gocount")
    for _, f := range files {
        if f.IsDir() || !strings.HasSuffix(f.Name(), ".json") {
            continue
        }
        data, err := os.ReadFile("/tmp/gocount/" + f.Name())
        if err != nil {
            fmt.Fprintf(os.Stderr, "Warning: could not read %s: %vn", f.Name(), err)
            continue
        }
        var c Container
        if err := json.Unmarshal(data, &c); err != nil {
            fmt.Fprintf(os.Stderr, "Warning: could not parse %s: %vn", f.Name(), err)
            continue
        }
        containers = append(containers, &c)
    }
    return containers, nil
}

Why two stores? The in-memory map is O(1) with no I/O overhead for commands that run in the same process lifetime as run. The JSON files persist state across invocations, so ps, stop, and inspect work even after the parent process exits.

Step 3: Downloading the Rootfs

A container needs a root filesystem. gocount downloads Alpine Linux minirootfs on first run. It’s only ~3 MB compressed and contains a full /bin/sh, ip, ping, python3, and apk.

// internal/rootfs/manager.go
const DefaultRootfsURL = "https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz"

func EnsureRootfs(rootfsDir string) error {
    if isValidRootfs(rootfsDir) {
        return nil
    }
    fmt.Println("Rootfs not found. Downloading Alpine Linux rootfs...")
    return DownloadAndExtractRootfs(DefaultRootfsURL, rootfsDir)
}

isValidRootfs checks that bin/, lib/, etc/, usr/, and bin/sh all exist. If any are missing the tarball is re-downloaded.

Security note: zip-slip protection. When extracting the tarball every header path is validated to stay inside the destination directory:

target := filepath.Join(destPath, header.Name)
if !strings.HasPrefix(
    filepath.Clean(target)+string(os.PathSeparator),
    filepath.Clean(destPath)+string(os.PathSeparator),
) {
    return fmt.Errorf("illegal path in tar archive: %s", header.Name)
}

A crafted tar with ../../etc/passwd entries would otherwise silently overwrite host files.

The rootfs is stored at /tmp/gocount/<id>/rootfs and reused across runs sharing the same ID. On the very first gocount run you’ll see:

Rootfs not found. Downloading Alpine Linux rootfs...
Downloading from https://dl-cdn.alpinelinux.org/alpine/...
Extracting rootfs...
Rootfs setup complete!

Subsequent runs are instant because isValidRootfs passes immediately.

Step 4: cgroup v2 Resource Limits

The cgroup code lives entirely in internal/cgroups/cgroups.go. The interface is simple:

// Create a cgroup for a container
cgPath, err := cgroups.Create(id)

// Apply limits (empty string = skip)
cgroups.SetMemoryLimit(cgPath, "100M")      // supports M and G suffixes
cgroups.SetCPUQuota(cgPath, "50000 100000") // quota/period in microseconds

// Add a process to the cgroup
cgroups.AddProc(cgPath, pid)

How memory limits are applied:

func SetMemoryLimit(cgPath, limit string) error {
    if limit == "" {
        return nil
    }
    val := limit
    if strings.HasSuffix(limit, "M") {
        mb, _ := strconv.ParseInt(strings.TrimSuffix(limit, "M"), 10, 64)
        val = strconv.FormatInt(mb*1024*1024, 10)
    } else if strings.HasSuffix(limit, "G") {
        gb, _ := strconv.ParseInt(strings.TrimSuffix(limit, "G"), 10, 64)
        val = strconv.FormatInt(gb*1024*1024*1024, 10)
    }
    writeFile(filepath.Join(cgPath, "memory.max"), val)
    writeFile(filepath.Join(cgPath, "memory.swap.max"), "0") // disable swap
    writeFile(filepath.Join(cgPath, "memory.oom.group"), "1") // kill whole cgroup on OOM
    return nil
}

CPU quota uses cgroup v2’s cpu.max format: <quota_microseconds> <period_microseconds>. For example "50000 100000" means 50 ms out of every 100 ms period, which works out to 50% of one CPU core.

Important: The cgroup is created before the child process starts, so limits are in place the moment the container process writes its PID to cgroup.procs.

Step 5: The run Command (Parent Side)

This is the heart of gocount. The run command works by re-executing itself, a classic self-re-exec pattern used by runc and lxc.

gocount run /bin/sh
       │
       ├─ parent: sets up cgroup, starts child with namespaces
       │
       └─ child (GOCOUNT_CHILD=1): pivot_root, mount /proc /sys /dev,
                                   wait for veth, configure eth0, exec /bin/sh

Why self-re-exec? Go’s runtime starts multiple threads before main(). clone(CLONE_NEWPID) in a multi-threaded process can leave threads in inconsistent namespaces. Re-executing /proc/self/exe gives us a fresh, single-threaded process that enters the namespace cleanly from the very start.

// cmd/run.go (parent side, simplified)
id := container.GenerateID()
rootdir := "/tmp/gocount/" + id + "/rootfs"

rootfs.EnsureRootfs(rootdir)

cgPath, _ := cgroups.Create(id)
cgroups.SetMemoryLimit(cgPath, flagMemory)
cgroups.SetCPUQuota(cgPath, flagCPU)

command := exec.Command("/proc/self/exe", append([]string{"run"}, args...)...)
command.Env = append(os.Environ(),
    "GOCOUNT_CHILD=1",
    "GOCOUNT_CONTAINER_ID="+id,
    "GOCOUNT_ROOTFS="+rootdir,
)
command.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS |
                syscall.CLONE_NEWPID |
                syscall.CLONE_NEWNS  |
                syscall.CLONE_NEWNET,
}

command.Start()
network.SetupVethPair(id, command.Process.Pid) // host-side networking

c := &container.Container{
    ID: id, Pid: command.Process.Pid,
    Command: args, Status: "running",
    RootFs: rootdir, Cgroup: cgPath,
}
container.Containers[id] = c
container.SaveContainer(c)

command.Wait()

Step 6: The run Command (Child Side)

When the child detects GOCOUNT_CHILD=1 it calls childSetup:

func childSetup(args []string) {
    rootfsPath  := os.Getenv("GOCOUNT_ROOTFS")
    containerID := os.Getenv("GOCOUNT_CONTAINER_ID")

    // Join the pre-created cgroup
    cgPath := filepath.Join("/sys/fs/cgroup", "gocount", containerID)
    cgroups.AddProc(cgPath, os.Getpid())

    // Switch to the isolated filesystem
    container.SetupMount(rootfsPath)

    // Set container hostname
    syscall.Sethostname([]byte("gocount"))

    // Wait up to 5s for the parent to wire the veth pair
    for i := 0; i < 50; i++ {
        if exec.Command("ip", "link", "show", "eth0").Run() == nil {
            break
        }
        time.Sleep(100 * time.Millisecond)
    }

    // Configure eth0 and default route
    network.SetupNetworkInsideContainer()

    // Replace this process with the user's command — it becomes PID 1
    syscall.Exec(args[0], args, os.Environ())
}

The syscall.Exec call is critical. It replaces the Go runtime with the container’s process so that the user’s command is PID 1 inside the container.

Step 7: Filesystem Isolation with pivot_root

SetupMount is called inside the child, after it enters the new mount namespace:

// internal/container/mount.go
func SetupMount(rootfs string) error {
    rootfs, _ = filepath.Abs(rootfs)

    // Make the current mount tree private so host cannot see our changes
    syscall.Mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, "")

    // Bind mount rootfs onto itself (kernel requirement for pivot_root)
    syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND|syscall.MS_REC, "")

    // Create a directory to receive the old root
    putold := filepath.Join(rootfs, ".pivot_root")
    os.MkdirAll(putold, 0700)

    // Swap the root mount
    syscall.PivotRoot(rootfs, putold)
    os.Chdir("/")

    // Detach and remove the old root
    syscall.Unmount("/.pivot_root", syscall.MNT_DETACH)
    os.RemoveAll("/.pivot_root")

    // Mount essential virtual filesystems
    syscall.Mount("proc",  "/proc", "proc",  0, "")
    syscall.Mount("sysfs", "/sys",  "sysfs", 0, "")
    syscall.Mount("tmpfs", "/dev",  "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755")

    createDeviceNodes() // /dev/null, /dev/zero, /dev/urandom, etc.
    setupDNS()          // write 8.8.8.8 to /etc/resolv.conf
    return nil
}

After PivotRoot, / is the Alpine rootfs. The host’s /home, /etc, /proc — none of it is visible.

Step 8: Networking with veth Pairs

// internal/network/network.go (host side)
func SetupVethPair(containerID string, pid int) error {
    hostIf      := fmt.Sprintf("veth-%s", containerID[:8])  // e.g. veth-a1b2c3d4
    containerIf := fmt.Sprintf("vethc-%s", containerID[:7]) // temporary name

    // Create the pair
    exec.Command("ip", "link", "add", hostIf, "type", "veth", "peer", "name", containerIf).Run()

    // Move one end into the container's network namespace
    exec.Command("ip", "link", "set", containerIf, "netns", fmt.Sprintf("%d", pid)).Run()

    // Rename to eth0 inside the container namespace
    exec.Command("nsenter", "-t", fmt.Sprintf("%d", pid), "-n",
        "ip", "link", "set", containerIf, "name", "eth0").Run()

    // Bring up and address the host end
    exec.Command("ip", "link", "set", hostIf, "up").Run()
    exec.Command("ip", "addr", "add", "10.0.0.1/24", "dev", hostIf).Run()

    EnableIPForwarding()
    SetupNAT() // iptables MASQUERADE for 10.0.0.0/24
    return nil
}

Inside the container, SetupNetworkInsideContainer mirrors this:

exec.Command("ip", "link", "set", "lo",   "up").Run()
exec.Command("ip", "link", "set", "eth0", "up").Run()
exec.Command("ip", "addr", "add", "10.0.0.2/24", "dev", "eth0").Run()
exec.Command("ip", "route", "add", "default", "via", "10.0.0.1").Run()

The container’s DNS is 8.8.8.8 (written by setupDNS into /etc/resolv.conf inside the new rootfs).

Step 9: Container Lifecycle Commands

ps — list containers

Reads every *.json file from /tmp/gocount/ and prints a table:

CONTAINER ID    PID     STATUS    COMMAND
a1b2c3d4        12345   running   [/bin/sh]

stop — send SIGKILL

syscall.Kill(c.Pid, syscall.SIGKILL)
c.Status = "stopped"
container.SaveContainer(c)

rm — stop and remove metadata

Kills the process (if still running), removes the JSON file, and deletes it from the in-memory map.

inspect — detailed view

Shows PID, status, cgroup resource limits, memory usage, CPU time, and every namespace link:

Container Information:
  ID:        ar9vb2mm
  Status:    running
  PID:       57699
  Command:   [/bin/sh]
  RootFS:    /tmp/gocount/ar9vb2mm/rootfs
  Cgroup:    /sys/fs/cgroup/gocount/ar9vb2mm

Process Status:
  Running:   Yes
  State:     S (sleeping)

Resource Limits:
  Memory Limit:    unlimited
  Memory Usage:    319488 bytes (312.00 KB)
  Memory Peak:     2838528 bytes (2.71 MB)
  CPU Quota:       max/100000 (0.0%)
  CPU Time:        36.04ms

Memory Events:

Processes in Cgroup: 1
  PID: 57699

Namespaces:
  cgroup: cgroup:[4026531835]
  ipc: ipc:[4026531839]
  mnt: mnt:[4026533179]
  net: net:[4026533458]
  pid: pid:[4026533456]
  pid_for_children: pid:[4026533456]
  time: time:[4026531834]
  time_for_children: time:[4026531834]
  user: user:[4026531837]
  uts: uts:[4026533455]

isProcessRunning sends syscall.Signal(0), a no-op signal that returns an error only if the process doesn’t exist:

func isProcessRunning(pid int) bool {
    process, err := os.FindProcess(pid)
    if err != nil {
        return false
    }
    return process.Signal(syscall.Signal(0)) == nil
}

Step 10: Build and Run

# Build
make build
# Binary lands at bin/gocount

# Run an interactive Alpine shell
sudo ./bin/gocount run /bin/sh

# Run with resource limits
sudo ./bin/gocount run --memory 100M --cpu "50000 100000" /bin/sh

# In another terminal — list containers
sudo ./bin/gocount ps

# Inspect
sudo ./bin/gocount inspect <id>

# Stop
sudo ./bin/gocount stop <id>

# Remove
sudo ./bin/gocount rm <id>

Testing the memory limit

test.py allocates 1 MB per iteration. With a 50 MB limit you can watch it get OOM-killed:

make test-memory
# Copies test.py into the rootfs, then runs:
# sudo gocount run --memory 50M /usr/bin/python3 /test.py

Output:

Starting memory consumption...
Allocated: 1 MB
Allocated: 2 MB
...
Allocated: 48 MB
Allocated: 49 MB
Killed

The kernel’s OOM killer fires the moment the cgroup’s memory.max threshold is crossed and terminates the entire cgroup.

How It All Fits Together: End-to-End Walk-through

$ sudo ./bin/gocount run --memory 50M /bin/sh

1. GenerateID()               → "a1b2c3d4"
2. EnsureRootfs(...)          → download Alpine if missing
3. cgroups.Create("a1b2c3d4") → mkdir /sys/fs/cgroup/gocount/a1b2c3d4
4. SetMemoryLimit(..., "50M") → write 52428800 to memory.max
5. exec.Command("/proc/self/exe", "run", "/bin/sh")
   + GOCOUNT_CHILD=1
   + CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET
6. command.Start()            → child PID = 12345
7. SetupVethPair("a1b2c3d4", 12345)
   → ip link add veth-a1b2c3d4 type veth peer name vethc-a1b2c3
   → ip link set vethc-a1b2c3 netns 12345
   → nsenter -t 12345 -n ip link set vethc-a1b2c3 name eth0
   → ip addr add 10.0.0.1/24 dev veth-a1b2c3d4
   → iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -j MASQUERADE
8. SaveContainer(...)         → /tmp/gocount/a1b2c3d4.json

--- inside child (GOCOUNT_CHILD=1) ---
9.  AddProc(cgPath, getpid()) → write PID to cgroup.procs
10. SetupMount(rootfsPath)    → pivot_root + mount /proc /sys /dev
11. Sethostname("gocount")
12. poll until eth0 appears   → up to 5 s
13. SetupNetworkInsideContainer()
    → ip link set lo up
    → ip link set eth0 up
    → ip addr add 10.0.0.2/24 dev eth0
    → ip route add default via 10.0.0.1
14. syscall.Exec("/bin/sh", ...) → you are now in the container

Key Bugs Fixed Along the Way

Building a container runtime means touching the kernel directly. A few subtle bugs appeared during development:

Bug Impact Fix
Duplicate init() in inspect.go Compile error Remove the second registration
process.Signal(os.Signal(nil)) Panic: nil interface type assertion Use syscall.Signal(0)
removeCmd missing nil check Panic when container not found Guard c == nil before access
AddContainer called after Containers[id] = c Overwrites Cgroup field with zero value Remove the redundant AddContainer call
Zip-slip in extractTar Malicious tar writes outside rootfs strings.HasPrefix boundary check
startCmd missing CLONE_NEWNET Container shares host network stack Add CLONE_NEWNET to clone flags
json.Unmarshal errors ignored Corrupted JSON inserts zero-value container Explicit error check + continue
rand.Seed(time.Now().UnixNano()) Deprecated in Go 1.20+ Removed: global source is auto-seeded

What’s Missing (and How to Add It)

gocount is deliberately minimal. Here’s what a production runtime adds on top:

Feature What to do
User namespace (CLONE_NEWUSER) Map container root to an unprivileged host UID — no more sudo
IPC namespace (CLONE_NEWIPC) Isolate System V semaphores and POSIX message queues
Rootfs layers / overlay Use overlayfs so containers share a read-only base and get a private writable layer
Container images Pull from an OCI registry (skopeo / go-containerregistry)
seccomp filter Block dangerous syscalls (ptrace, mount, reboot) using libseccomp
Capabilities drop Use AmbientCaps + CapDrop in SysProcAttr to drop CAP_NET_ADMIN after setup
Port forwarding Add iptables DNAT rules: host port to container IP:port
Persistent storage Bind-mount host directories into the container before pivot_root
Multi-container networking Replace individual veth pairs with a Linux bridge (like Docker’s docker0)

Full Source

The complete source for gocount is structured exactly as shown above. The key files:

gocount is ~600 lines of Go and it’s a real container runtime. The kernel was doing all of this every time you typed docker run. Now you know exactly how.